diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index aca1104..a72f4bc 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -43,12 +43,12 @@ jobs: - name: Integration tests working-directory: backend run: make test-integration - - name: Mixed protocol, cancellation and billing concurrency regression + - name: Native provider tool and streaming regression working-directory: backend - run: go test -race -p 1 -tags=unit ./internal/service ./internal/handler ./migrations -run 'TestMixed' -count=1 - - name: Key scope and user-rate revision concurrency regression + run: go test -race -p 1 -tags=unit ./internal/service ./internal/handler ./migrations -run 'TestNativeProvider' -count=1 + - name: Legacy admission and user-rate revision concurrency regression working-directory: backend - run: go test -race -p 1 -tags=unit ./internal/service ./internal/handler ./internal/server/middleware ./internal/repository -run 'Test(.*ModelScope|.*ScopeSelection|.*ScopeTransport|.*ScopeForward|.*ScopeWS|UserGroupRateRevision|UserGroupRateRepositoryRevision)' -count=1 + run: go test -race -p 1 -tags=unit ./internal/service ./internal/handler ./internal/server/middleware ./internal/repository -run 'Test(LegacyPlatform|UserGroupRateRevision|UserGroupRateRepositoryRevision)' -count=1 - name: Codex ticket concurrency regression working-directory: backend run: go test -race -tags=unit ./internal/service -run 'Test.*CodexTicket' -count=1 diff --git a/LOCAL_FORK.md b/LOCAL_FORK.md index ef77a3a..389f746 100644 --- a/LOCAL_FORK.md +++ b/LOCAL_FORK.md @@ -36,3 +36,7 @@ EPUSDT 支付接口不再预填第三方站点,需管理员明确填写自己 - 已导入的上游版本和完整提交记录在 `maintenance/upstream.lock.json`。同步前先审查该提交到目标提交的差异、迁移、依赖和部署边界。 - 审查通过后,将变更以维护者自己的单个提交同步到本仓库,保留本地补丁,并更新锁文件;不导入上游 Git 历史。 - VPS 只接受 `loLollipop/Sub2api` 发布的、已校验的 Linux amd64 制品。生产继续设置 `UPDATE_STRATEGY=disabled`,禁止应用直接替换自身。 + +## 原生平台恢复(2026-10-16) + +Mixed 聚合平台及其协议探测回退、厂商倍率、API Key 四类互斥 scope 已退役。Kimi、智谱、DeepSeek、MiniMax 恢复独立账号与分组入口,Command Code、Cline、TypeSafe 保留既有原生链路。历史 mixed 对象和非 all scope Key 会明确拒绝,需要维护者按已有配置显式迁移;应用不会按域名猜测平台。已应用的 255–266 迁移及存量兼容列保持不变,无自动账务迁移。 diff --git a/Makefile b/Makefile index 0cdd5af..950f8e6 100644 --- a/Makefile +++ b/Makefile @@ -16,9 +16,7 @@ FRONTEND_CRITICAL_VITEST := \ src/components/admin/__tests__/ErrorPassthroughRulesModal.toggle.spec.ts \ src/components/admin/group/__tests__/GroupRateMultipliersModal.requests.spec.ts \ src/components/admin/user/__tests__/UserAllowedGroupsModal.rates.spec.ts \ - src/components/common/__tests__/MixedProviderRates.spec.ts \ src/components/common/__tests__/PlatformIcon.spec.ts \ - src/utils/__tests__/mixed-provider-rates.spec.ts \ src/components/modelPlaza/__tests__/PlazaGroupSection.spec.ts \ src/components/modelPlaza/__tests__/PlazaModelPricingTable.spec.ts \ src/components/modelPlaza/__tests__/ModelPlazaContent.spec.ts \ diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 2f1a5aa..d302656 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -2.1.17 +2.1.18 diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 85e2bde..55f9042 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -20,19 +20,18 @@ const ( const ( PlatformAnthropic = "anthropic" PlatformOpenAI = "openai" - PlatformMixed = "mixed" - PlatformCommandCode = "command_code" - PlatformCline = "cline" - PlatformTypeSafe = "typesafe" PlatformGemini = "gemini" PlatformAntigravity = "antigravity" PlatformGrok = "grok" // 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。 - PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot) - PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel) - PlatformDeepseek = "deepseek" // DeepSeek - PlatformMiniMax = "minimax" // MiniMax (M 系列) - PlatformComposite = "composite" + PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot) + PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel) + PlatformDeepseek = "deepseek" // DeepSeek + PlatformMiniMax = "minimax" // MiniMax (M 系列) + PlatformComposite = "composite" + PlatformCommandCode = "command_code" + PlatformCline = "cline" + PlatformTypeSafe = "typesafe" ) // Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。 diff --git a/backend/internal/domain/platforms.go b/backend/internal/domain/platforms.go index b6cacd7..1ce9976 100644 --- a/backend/internal/domain/platforms.go +++ b/backend/internal/domain/platforms.go @@ -45,7 +45,6 @@ var platformList = []PlatformSpec{ {ID: PlatformZhipu, DisplayName: "Zhipu GLM", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "zhipu"}, {ID: PlatformDeepseek, DisplayName: "DeepSeek", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "deepseek"}, {ID: PlatformMiniMax, DisplayName: "MiniMax", Gateway: PlatformGatewayOpenAI, CNProvider: true, LiteLLMProvider: "minimax"}, - {ID: PlatformMixed, DisplayName: "Mixed", Gateway: PlatformGatewayOpenAI}, {ID: PlatformCommandCode, DisplayName: "Command Code", Gateway: PlatformGatewayOpenAI}, {ID: PlatformCline, DisplayName: "Cline", Gateway: PlatformGatewayOpenAI}, {ID: PlatformTypeSafe, DisplayName: "TypeSafe", Gateway: PlatformGatewaySystemOne}, diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 7a4b739..dd99e97 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -2831,20 +2831,6 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) { return } - // Mixed uses the configured catalog. Opening the test picker must not probe - // models or invent provider defaults for a multi-model upstream. - if account.IsMixed() { - models := make([]openai.Model, 0) - for model := range account.GetModelMapping() { - if strings.Contains(model, "*") { - continue - } - models = append(models, openai.Model{ID: model, DisplayName: model}) - } - sort.Slice(models, func(i, j int) bool { return models[i].ID < models[j].ID }) - response.Success(c, models) - return - } // Handle OpenAI accounts if account.IsOpenAI() { // Prefer the shared, account-keyed upstream catalog. If discovery fails, diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 6474bce..429f85f 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -193,19 +193,18 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) { // CreateGroupRequest represents create group request type CreateGroupRequest struct { - I18n service.GroupI18n `json:"i18n"` - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Platform string `json:"platform" binding:"omitempty,group_platform"` - RateMultiplier float64 `json:"rate_multiplier"` - MixedProviderRateMultipliers service.MixedProviderRateMultipliers `json:"mixed_provider_rate_multipliers"` - IsExclusive bool `json:"is_exclusive"` - SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` - DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` - WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` - MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` - LongContextPricingEnabled bool `json:"long_context_pricing_enabled"` - ModelPricing []service.ChannelModelPricing `json:"model_pricing"` + I18n service.GroupI18n `json:"i18n"` + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax cline command_code typesafe composite"` + RateMultiplier float64 `json:"rate_multiplier"` + IsExclusive bool `json:"is_exclusive"` + SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` + DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` + WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` + MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` + LongContextPricingEnabled bool `json:"long_context_pricing_enabled"` + ModelPricing []service.ChannelModelPricing `json:"model_pricing"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) AllowImageGeneration bool `json:"allow_image_generation"` AllowBatchImageGeneration bool `json:"allow_batch_image_generation"` @@ -270,20 +269,19 @@ type CreateGroupRequest struct { // UpdateGroupRequest represents update group request type UpdateGroupRequest struct { - Name string `json:"name"` - Description *string `json:"description"` - I18n *service.GroupI18n `json:"i18n"` - Platform string `json:"platform" binding:"omitempty,group_platform"` - RateMultiplier *float64 `json:"rate_multiplier"` - MixedProviderRateMultipliers service.MixedProviderRateMultipliers `json:"mixed_provider_rate_multipliers"` - IsExclusive *bool `json:"is_exclusive"` - Status string `json:"status" binding:"omitempty,oneof=active inactive"` - SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` - DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` - WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` - MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` - LongContextPricingEnabled *bool `json:"long_context_pricing_enabled"` - ModelPricing *[]service.ChannelModelPricing `json:"model_pricing"` + I18n *service.GroupI18n `json:"i18n"` + Name string `json:"name"` + Description *string `json:"description"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax cline command_code typesafe composite"` + RateMultiplier *float64 `json:"rate_multiplier"` + IsExclusive *bool `json:"is_exclusive"` + Status string `json:"status" binding:"omitempty,oneof=active inactive"` + SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` + DailyLimitUSD optionalLimitField `json:"daily_limit_usd"` + WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` + MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` + LongContextPricingEnabled *bool `json:"long_context_pricing_enabled"` + ModelPricing *[]service.ChannelModelPricing `json:"model_pricing"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) AllowImageGeneration *bool `json:"allow_image_generation"` AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"` @@ -684,7 +682,6 @@ func (h *GroupHandler) Create(c *gin.Context) { I18n: req.I18n, Platform: req.Platform, RateMultiplier: req.RateMultiplier, - MixedProviderRateMultipliers: req.MixedProviderRateMultipliers, IsExclusive: req.IsExclusive, SubscriptionType: req.SubscriptionType, DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(), @@ -832,7 +829,6 @@ func (h *GroupHandler) Update(c *gin.Context) { I18n: req.I18n, Platform: req.Platform, RateMultiplier: req.RateMultiplier, - MixedProviderRateMultipliers: req.MixedProviderRateMultipliers, IsExclusive: req.IsExclusive, Status: req.Status, SubscriptionType: req.SubscriptionType, diff --git a/backend/internal/handler/admin/group_handler_platform_test.go b/backend/internal/handler/admin/group_handler_platform_test.go index 643e930..36a4ee8 100644 --- a/backend/internal/handler/admin/group_handler_platform_test.go +++ b/backend/internal/handler/admin/group_handler_platform_test.go @@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error { func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) { allowed := []string{ "anthropic", "openai", "gemini", "antigravity", "grok", - "kimi", "zhipu", "deepseek", "minimax", "mixed", "command_code", "cline", "typesafe", "composite", + "kimi", "zhipu", "deepseek", "minimax", "command_code", "cline", "typesafe", "composite", } for _, platform := range allowed { t.Run("create_"+platform, func(t *testing.T) { @@ -54,7 +54,7 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) { "openai ", // 尾随空格 "glm", "bogus", - "opencode_go", "TypeSafe", "typesafe ", + "mixed", "opencode_go", "TypeSafe", "typesafe ", } for _, platform := range invalid { t.Run("create_"+platform, func(t *testing.T) { @@ -73,10 +73,15 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) { } func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) { - for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "mixed", "command_code", "cline", "typesafe"} { + for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "command_code", "cline", "typesafe"} { var req CompositeRouteRequest body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform) require.NoError(t, bindGroupPlatformJSON(t, &req, body)) require.Equal(t, platform, req.TargetPlatform) } } + +func TestCompositeRouteTargetPlatform_RejectsRetiredMixed(t *testing.T) { + var req CompositeRouteRequest + require.Error(t, bindGroupPlatformJSON(t, &req, `{"public_model":"m","target_platform":"mixed"}`)) +} diff --git a/backend/internal/handler/api_key_handler.go b/backend/internal/handler/api_key_handler.go index dfcfdd3..f1145fe 100644 --- a/backend/internal/handler/api_key_handler.go +++ b/backend/internal/handler/api_key_handler.go @@ -31,13 +31,11 @@ func NewAPIKeyHandler(apiKeyService *service.APIKeyService) *APIKeyHandler { } func ProvideAPIKeyHandler(apiKeyService *service.APIKeyService, catalogue *service.GatewayService) *APIKeyHandler { - apiKeyService.SetGroupCatalogue(catalogue) return NewAPIKeyHandler(apiKeyService) } // CreateAPIKeyRequest represents the create API key request payload type CreateAPIKeyRequest struct { - ModelScope string `json:"model_scope"` Name string `json:"name" binding:"required"` GroupID *int64 `json:"group_id"` // nullable GroupIDs []int64 `json:"group_ids"` @@ -59,7 +57,6 @@ type CreateAPIKeyRequest struct { // UpdateAPIKeyRequest represents the update API key request payload type UpdateAPIKeyRequest struct { - ModelScope *string `json:"model_scope"` Name string `json:"name"` GroupID *int64 `json:"group_id"` GroupIDs *[]int64 `json:"group_ids"` @@ -235,7 +232,6 @@ func (h *APIKeyHandler) Create(c *gin.Context) { svcReq.RateLimit7d = *req.RateLimit7d } svcReq.AnthropicCacheTTLMode = req.AnthropicCacheTTLMode - svcReq.ModelScope = req.ModelScope executeUserIdempotentJSON(c, "user.api_keys.create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { key, err := h.apiKeyService.Create(ctx, subject.UserID, svcReq) @@ -282,7 +278,6 @@ func (h *APIKeyHandler) Update(c *gin.Context) { ResetRateLimitUsage: req.ResetRateLimitUsage, AnthropicCacheTTLMode: req.AnthropicCacheTTLMode, - ModelScope: req.ModelScope, } if req.Name != "" { svcReq.Name = &req.Name @@ -358,13 +353,7 @@ func (h *APIKeyHandler) GetAvailableGroups(c *gin.Context) { out := make([]dto.Group, 0, len(groups)) for i := range groups { - groupDTO := dto.GroupFromService(&groups[i]).Localized(c.GetHeader("Accept-Language")) - groupDTO.SupportedKeyProviders = groups[i].SupportedKeyProviders - // Empty is meaningful for Mixed; it must not trigger platform fallback. - if groupDTO.SupportedKeyProviders == nil { - groupDTO.SupportedKeyProviders = []string{} - } - out = append(out, *groupDTO) + out = append(out, *dto.GroupFromService(&groups[i]).Localized(c.GetHeader("Accept-Language"))) } response.Success(c, out) } diff --git a/backend/internal/handler/api_key_model_scope_test.go b/backend/internal/handler/api_key_model_scope_test.go deleted file mode 100644 index 34553ab..0000000 --- a/backend/internal/handler/api_key_model_scope_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "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" -) - -func TestSameMixedGroupFourAPIKeyCatalogues(t *testing.T) { - gid := int64(218) - h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{gid: { - {ID: 1, Platform: service.PlatformMixed, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{ - "gpt-5": "gpt-5", "claude-4": "claude-4", "qwen3": "qwen3", "gemini-3": "gemini-3", "grok-4": "grok-4", "gpt-cross": "qwen3", "claude-opaque": "raw-target", - }}}, - }}}) - for scope, expected := range map[string][]string{"openai": {"gpt-5"}, "anthropic": {"claude-4"}, "cn": {"qwen3"}, "other": {"gemini-3", "grok-4"}} { - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) - c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ModelScope: scope, GroupID: &gid, Group: &service.Group{ID: gid, Platform: service.PlatformMixed}}) - h.Models(c) - require.Equal(t, http.StatusOK, recorder.Code) - var result gatewayModelsResponseForTest - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result)) - require.ElementsMatch(t, expected, modelIDsForTest(result.Data)) - } -} - -func TestModelScopeCatalogueManifestETagAndIsolation(t *testing.T) { - original := []byte(`{"models":[{"slug":"gpt-5"},{"slug":"qwen3"}],"metadata":"keep"}`) - manifest := &service.OpenAIModelsResponse{Body: original, ETag: "unfiltered"} - write := func(scope, etag string) *httptest.ResponseRecorder { - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodGet, "/backend-api/codex/models", nil) - c.Request.Header.Set("If-None-Match", etag) - c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ModelScope: scope}) - writeOpenAIModelsResponse(c, manifest) - return recorder - } - openai := write("openai", "unfiltered") - require.Equal(t, http.StatusOK, openai.Code) - require.NotContains(t, openai.Body.String(), "qwen3") - cn := write("cn", openai.Header().Get("ETag")) - require.Equal(t, http.StatusOK, cn.Code) - require.NotContains(t, cn.Body.String(), "gpt-5") - require.Equal(t, http.StatusNotModified, write("cn", cn.Header().Get("ETag")).Code) - require.Equal(t, original, manifest.Body) - require.Equal(t, "unfiltered", manifest.ETag) -} - -func TestWSKeyScopeCandidateInheritance(t *testing.T) { - key := &service.APIKey{ModelScope: "cn"} - require.Empty(t, blockedAPIKeyModelCandidate(key, []string{"qwen3"})) - require.Equal(t, "gpt-5", blockedAPIKeyModelCandidate(key, []string{"qwen3", "gpt-5"})) - key.ModelScope = "all" - require.Empty(t, blockedAPIKeyModelCandidate(key, []string{"qwen3", "gpt-5"})) -} - -func TestModelScopeManifestFiltersAccountWildcardWithoutMutatingSource(t *testing.T) { - gid := int64(218) - h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{gid: { - {Platform: service.PlatformMixed, Credentials: map[string]any{"model_mapping": map[string]any{"gpt-*": "qwen3", "gpt-safe": "gpt-5"}}}, - }}}) - original := []byte(`{"models":[{"slug":"gpt-5","extra":true},{"slug":"gpt-safe","extra":true}],"metadata":"keep"}`) - filtered, err := filterModelCatalogueForScope(original, "openai", func(models []string) []string { - return h.gatewayService.FilterModelsForKeyScope(t.Context(), &gid, "openai", models) - }) - require.NoError(t, err) - require.NotContains(t, string(filtered), `"slug":"gpt-5"`) - require.Contains(t, string(filtered), `"slug":"gpt-safe"`) - require.Contains(t, string(filtered), `"extra":true`) - require.Contains(t, string(filtered), `"metadata":"keep"`) - require.Contains(t, string(original), `"slug":"gpt-5"`) -} diff --git a/backend/internal/handler/balance_preauthorization_test.go b/backend/internal/handler/balance_preauthorization_test.go index b2eb32c..3561e7c 100644 --- a/backend/internal/handler/balance_preauthorization_test.go +++ b/backend/internal/handler/balance_preauthorization_test.go @@ -50,7 +50,7 @@ func (s *pricingProviderStub) BalancePreauthorizationCostInput(_ context.Context } func TestPreauthorizeTextFreeFastInitialTier(t *testing.T) { - for _, platform := range []string{service.PlatformOpenAI, service.PlatformComposite, service.PlatformMixed} { + for _, platform := range []string{service.PlatformOpenAI, service.PlatformComposite} { for _, tier := range []string{"priority", " FAST ", "ultrafast", "flex"} { t.Run(platform+"/"+tier, func(t *testing.T) { preauthorizer := &preauthorizerStub{requires: true} @@ -61,7 +61,7 @@ func TestPreauthorizeTextFreeFastInitialTier(t *testing.T) { []byte(`{"model":"gpt-6.1-sol"}`), "gpt-6.1-sol", time.Unix(123, 0), tier) require.NoError(t, err) want := strings.ToLower(strings.TrimSpace(tier)) - if platform != service.PlatformMixed && (want == "priority" || want == "fast") { + if want == "priority" || want == "fast" { want = "" } require.Equal(t, want, pricing.lastTier) diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 693a423..eb0094d 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -188,8 +188,6 @@ func groupFromServiceBase(g *service.Group) Group { I18n: g.I18n, Platform: g.Platform, RateMultiplier: g.RateMultiplier, - MixedProviderRateMultipliers: service.CloneMixedProviderRateMultipliers(g.MixedProviderRateMultipliers), - SupportedModelVendors: append([]string{}, g.SupportedModelVendors...), IsExclusive: g.IsExclusive, Status: g.Status, SubscriptionType: g.SubscriptionType, diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 9229ccf..3c5f67d 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -95,16 +95,13 @@ type APIKey struct { } type Group struct { - SupportedKeyProviders []string `json:"supported_key_providers"` - SupportedModelVendors []string `json:"supported_model_vendors"` - MixedProviderRateMultipliers map[string]float64 `json:"mixed_provider_rate_multipliers,omitempty"` - ID int64 `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Platform string `json:"platform"` - RateMultiplier float64 `json:"rate_multiplier"` - IsExclusive bool `json:"is_exclusive"` - Status string `json:"status"` + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Platform string `json:"platform"` + RateMultiplier float64 `json:"rate_multiplier"` + IsExclusive bool `json:"is_exclusive"` + Status string `json:"status"` // I18n 名称与描述的各语言译文。只有管理端输出;用户侧接口经 Localized 把 // name/description 换成请求语言的版本后清空本字段。 diff --git a/backend/internal/handler/endpoint.go b/backend/internal/handler/endpoint.go index 629095e..e1af0bd 100644 --- a/backend/internal/handler/endpoint.go +++ b/backend/internal/handler/endpoint.go @@ -321,7 +321,7 @@ func GetUpstreamEndpoint(c *gin.Context, platform string) string { // OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。 // 这对 force_chat_completions 的错误路径尤为重要:此时可能没有 // ForwardResult,不能把入站 /v1/responses 误报成上游端点。 - if platform == service.PlatformOpenAI || platform == service.PlatformGrok || platform == service.PlatformGemini || service.IsCNProvider(platform) { + if platform == service.PlatformOpenAI || platform == service.PlatformGrok || platform == service.PlatformGemini || service.IsMultiProtocolAPIKeyProvider(platform) { if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" { return endpoint } diff --git a/backend/internal/handler/endpoint_test.go b/backend/internal/handler/endpoint_test.go index f87b27f..c0e4e64 100644 --- a/backend/internal/handler/endpoint_test.go +++ b/backend/internal/handler/endpoint_test.go @@ -251,6 +251,15 @@ func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) { // responsesSubpathSuffix // ────────────────────────────────────────────────────────── +func TestNativeProviderErrorEndpointUsesActualUpstream(t *testing.T) { + for _, platform := range []string{service.PlatformCline, service.PlatformCommandCode} { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + service.SetActualOpenAIUpstreamEndpoint(c, EndpointChatCompletions) + require.Equal(t, EndpointChatCompletions, GetUpstreamEndpoint(c, platform), platform) + } +} + func TestResponsesSubpathSuffix(t *testing.T) { tests := []struct { raw string diff --git a/backend/internal/handler/gateway_composite_route_models_test.go b/backend/internal/handler/gateway_composite_route_models_test.go index 08821c7..63eac6b 100644 --- a/backend/internal/handler/gateway_composite_route_models_test.go +++ b/backend/internal/handler/gateway_composite_route_models_test.go @@ -68,8 +68,6 @@ func TestGatewayModelsCompositeExactRouteCatalog(t *testing.T) { }{ {name: "ordinary", want: []string{"gpt-existing", "gpt-route", "gpt-responses", "gpt-chat", "gpt-hidden-target", "deepseek-v3.2"}}, {name: "codex endpoint", codex: true, want: []string{"gpt-existing", "gpt-route", "gpt-responses", "gpt-hidden-target", "deepseek-v3.2"}}, - {name: "openai scope", scope: service.ModelScopeOpenAI, want: []string{"gpt-existing", "gpt-route", "gpt-responses", "gpt-chat"}}, - {name: "codex openai scope", codex: true, scope: service.ModelScopeOpenAI, want: []string{"gpt-existing", "gpt-route", "gpt-responses"}}, {name: "allowlist", allowlist: []string{"gpt-route"}, want: []string{"gpt-route"}}, {name: "codex allowlist", codex: true, allowlist: []string{"gpt-route", "gpt-chat"}, want: []string{"gpt-route"}}, } { diff --git a/backend/internal/handler/gateway_domestic_models_test.go b/backend/internal/handler/gateway_domestic_models_test.go index 16156ac..abeebd1 100644 --- a/backend/internal/handler/gateway_domestic_models_test.go +++ b/backend/internal/handler/gateway_domestic_models_test.go @@ -1,70 +1 @@ package handler - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "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" -) - -func TestGatewayModelsDomesticCatalogAliasesRespectGroupAllowlist(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, selected := range [][]string{nil, {"kimi-k2.6"}, {"moonshotai/kimi-k2.6"}, {"unlisted-model"}} { - groupID := int64(218) - h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{ - groupID: {{ID: 1271, Platform: service.PlatformMixed, Type: service.AccountTypeAPIKey, - Credentials: map[string]any{"model_mapping": map[string]any{"moonshotai/kimi-k2.6": "raw-upstream", "Agents-A1": "Agents-A1"}}}}, - }}) - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) - c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &groupID, Group: &service.Group{ - ID: groupID, Platform: service.PlatformMixed, ModelAllowlist: service.GroupModelAllowlist{Enabled: selected != nil, Models: selected}, - }}) - h.Models(c) - require.Equal(t, http.StatusOK, recorder.Code) - var result gatewayModelsResponseForTest - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result)) - got := modelIDsForTest(result.Data) - switch { - case selected == nil: - require.ElementsMatch(t, []string{"moonshotai/kimi-k2.6", "kimi-k2.6", "Agents-A1"}, got) - case selected[0] == "unlisted-model": - require.Empty(t, got) - default: - require.Equal(t, selected, got) - } - } -} - -func TestMixedKeyCatalogIncludesSingleRelayModels(t *testing.T) { - gin.SetMode(gin.TestMode) - groupID := int64(218) - h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{ - groupID: {{ID: 1271, Platform: service.PlatformMixed, Type: service.AccountTypeAPIKey, - Credentials: map[string]any{"model_mapping": map[string]any{ - "qwen/qwen3.8-27b": "qwen/qwen3.8-27b", - "z-ai/glm-5.3": "z-ai/glm-5.3", - "deepseek-ai/deepseek-v4-pro": "deepseek-ai/deepseek-v4-pro", - }}}}, - }}) - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) - c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &groupID, Group: &service.Group{ - ID: groupID, Platform: service.PlatformMixed, - }}) - h.Models(c) - require.Equal(t, http.StatusOK, recorder.Code) - var result gatewayModelsResponseForTest - require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &result)) - require.ElementsMatch(t, []string{ - "qwen/qwen3.8-27b", "qwen3.8-27b", "z-ai/glm-5.3", "glm-5.3", - "deepseek-ai/deepseek-v4-pro", "deepseek-v4-pro", - }, modelIDsForTest(result.Data), "one relay account exposes all mapped models and existing public aliases") -} diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 0b0f6c2..fad9a45 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -24,6 +24,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" + "github.com/Wei-Shaw/sub2api/internal/pkg/typesafe" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" "github.com/Wei-Shaw/sub2api/internal/securityaudit" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -419,9 +420,6 @@ func (h *GatewayHandler) Messages(c *gin.Context) { apiKey = routedKey } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformGemini) cls = classifySelectionFailureErrorFromGin(c, err, cls) @@ -650,9 +648,6 @@ func (h *GatewayHandler) Messages(c *gin.Context) { }) } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), apiKey.GroupID, sessionKey, account, err) var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { @@ -781,9 +776,6 @@ func (h *GatewayHandler) Messages(c *gin.Context) { currentAPIKey = routedKey } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, currentAPIKey, reqModel, reqModel, platform) cls = classifySelectionFailureErrorFromGin(c, err, cls) @@ -1100,9 +1092,6 @@ func (h *GatewayHandler) Messages(c *gin.Context) { } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), currentAPIKey.GroupID, sessionKey, account, err) // Beta policy block: return 400 immediately, no failover var betaBlockedErr *service.BetaBlockedError @@ -1291,11 +1280,6 @@ func (h *GatewayHandler) Models(c *gin.Context) { } availableModels := h.availableModelsForAPIKey(c.Request.Context(), apiKey, forcedPlatform) - if apiKey != nil && service.NormalizeModelScope(apiKey.ModelScope) != service.ModelScopeAll && - (responsePlatform != service.PlatformOpenAI || apiKey.Group == nil || !apiKey.Group.CodexModelsManifestConfig.Enabled) { - writeModelsList(c, responsePlatform, availableModels) - return - } // Custom list on the primary group only changes the OpenAI/Claude wire // shape. Filtering already happened per bound group so a later group's @@ -1333,9 +1317,8 @@ func (h *GatewayHandler) Models(c *gin.Context) { writeGrokModelsList(c, xai.DefaultModelIDs()) return } - - if responsePlatform == service.PlatformMixed { - writeOpenAIModelsList(c, nil) + if responsePlatform == service.PlatformTypeSafe { + writeModelsList(c, responsePlatform, []string{typesafe.JevLatestModel}) return } writeModelsListResponse(c, claude.DefaultModels) @@ -1365,38 +1348,16 @@ func (h *GatewayHandler) groupForModelsList(ctx context.Context, apiKey *service } func (h *GatewayHandler) availableModelsForAPIKey(ctx context.Context, apiKey *service.APIKey, forcedPlatform string) []string { - if apiKey != nil { - ctx = service.ContextWithModelScope(ctx, apiKey.ModelScope) - } groupIDs := apiKeyModelsListGroupIDs(apiKey) if len(groupIDs) == 0 { - models := h.modelsForGroupID(ctx, nil, nil, forcedPlatform, false) - if apiKey != nil { - models = service.FilterModelScope(apiKey.ModelScope, models) - } - return models + return h.modelsForGroupID(ctx, nil, nil, forcedPlatform, false) } injectDefaults := len(groupIDs) > 1 var models []string for _, id := range groupIDs { gid := id group := h.groupForModelsList(ctx, apiKey, gid) - groupModels := h.modelsForGroupID(ctx, &gid, group, forcedPlatform, injectDefaults) - if apiKey != nil && service.NormalizeModelScope(apiKey.ModelScope) != service.ModelScopeAll { - if len(groupModels) == 0 && group != nil && group.Platform != service.PlatformMixed && !group.ModelAllowlistEnabled() { - groupModels = defaultModelIDsForPlatform(group.Platform) - } - if h.gatewayService != nil { - var endpoint []string - if group != nil && group.Platform == service.PlatformComposite { - endpoint = []string{""} - } - groupModels = h.gatewayService.FilterModelsForKeyScope(ctx, &gid, apiKey.ModelScope, groupModels, endpoint...) - } else { - groupModels = service.FilterModelScope(apiKey.ModelScope, groupModels) - } - } - models = mergeModelIDs(models, groupModels) + models = mergeModelIDs(models, h.modelsForGroupID(ctx, &gid, group, forcedPlatform, injectDefaults)) } return models } @@ -1462,13 +1423,8 @@ func (h *GatewayHandler) CodexModels(c *gin.Context) { if value, exists := middleware2.GetForcePlatformFromContext(c); exists { forcedPlatform = strings.TrimSpace(value) } - modelIDs := h.codexModelIDsForGroup(service.ContextWithModelScope(c.Request.Context(), apiKey.ModelScope), apiKey.Group, forcedPlatform) + modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group, forcedPlatform) modelIDs = service.FilterCodexModelIDsForGroup(modelIDs, apiKey.Group) - var routeEndpoint []string - if apiKey.Group.Platform == service.PlatformComposite && (forcedPlatform == "" || forcedPlatform == service.PlatformComposite) { - routeEndpoint = []string{service.CompositeRouteEndpointResponses} - } - modelIDs = h.gatewayService.FilterModelsForKeyScope(c.Request.Context(), &apiKey.Group.ID, apiKey.ModelScope, modelIDs, routeEndpoint...) body, err := h.gatewayService.BuildCodexModelsManifestForGroup( c.Request.Context(), apiKey.Group, @@ -1479,11 +1435,6 @@ func (h *GatewayHandler) CodexModels(c *gin.Context) { h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") return } - body, err = filterModelCatalogueForScope(body, apiKey.ModelScope) - if err != nil { - h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to filter models manifest") - return - } etag := service.CodexModelsManifestETag(body) c.Header("ETag", etag) if service.CodexModelsManifestETagMatches(c.GetHeader("If-None-Match"), etag) { @@ -1570,7 +1521,7 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * } func writeModelsList(c *gin.Context, platform string, modelIDs []string) { - if platform == service.PlatformOpenAI || platform == service.PlatformMixed { + if platform == service.PlatformOpenAI { writeOpenAIModelsList(c, modelIDs) return } @@ -1591,7 +1542,7 @@ func writeModelsList(c *gin.Context, platform string, modelIDs []string) { } func writeAllowlistedModelsList(c *gin.Context, platform string, modelIDs []string) { - if platform == service.PlatformOpenAI || platform == service.PlatformMixed { + if platform == service.PlatformOpenAI { writeOpenAIModelsList(c, modelIDs) return } @@ -1787,24 +1738,6 @@ func (h *GatewayHandler) AntigravityModels(c *gin.Context) { } models = filtered } - if key, ok := middleware2.GetAPIKeyFromContext(c); ok && key != nil && service.NormalizeModelScope(key.ModelScope) != service.ModelScopeAll { - ids := make([]string, 0, len(models)) - for _, model := range models { - ids = append(ids, model.ID) - } - allowed := h.gatewayService.FilterModelsForKeyScope(c.Request.Context(), key.GroupID, key.ModelScope, ids) - allow := make(map[string]bool, len(allowed)) - for _, id := range allowed { - allow[id] = true - } - filtered := make([]antigravity.ClaudeModel, 0, len(models)) - for _, model := range models { - if allow[model.ID] { - filtered = append(filtered, model) - } - } - models = filtered - } c.JSON(http.StatusOK, gin.H{ "object": "list", "data": models, diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index 6f55f4e..caf129d 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -240,9 +240,6 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { } } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform) cls = classifySelectionFailureErrorFromGin(c, err, cls) @@ -410,9 +407,6 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), apiKey.GroupID, selectionSessionHash, account, err) var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index a8d0183..cef218a 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -239,9 +239,6 @@ func (h *GatewayHandler) Responses(c *gin.Context) { } } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } if len(fs.FailedAccountIDs) == 0 { cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, effectiveAPIKeyPlatform(c, apiKey)) cls = classifySelectionFailureErrorFromGin(c, err, cls) @@ -394,9 +391,6 @@ func (h *GatewayHandler) Responses(c *gin.Context) { } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), apiKey.GroupID, sessionHash, account, err) var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { diff --git a/backend/internal/handler/gateway_model_scope_error.go b/backend/internal/handler/gateway_model_scope_error.go deleted file mode 100644 index 8bc0df5..0000000 --- a/backend/internal/handler/gateway_model_scope_error.go +++ /dev/null @@ -1,87 +0,0 @@ -package handler - -import ( - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" -) - -// A scope refusal is local authorization. Handle it before affinity, health, -// scheduler scoring or failover can attribute it to an upstream account. -func (h *OpenAIGatewayHandler) handleModelScopeError(c *gin.Context, err error, streamStarted bool) bool { - if !errors.Is(err, service.ErrModelScopeDenied) { - return false - } - service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied) - if service.OpenAIImagesJSONKeepalivePresent(c) { - // Images heartbeats commit JSON headers but write only whitespace, - // not SSE. Stop them before inspecting or writing the final document. - committed := service.StopOpenAIImagesJSONKeepaliveCommitted(c) - if service.IsResponseCommitted(c) || service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c) >= 0 { - return true - } - status := http.StatusForbidden - if committed { - status = c.Writer.Status() - service.MarkOpsStreamError(c, "permission_error", service.ErrModelScopeDenied.Message, http.StatusForbidden) - } - c.JSON(status, gin.H{"error": gin.H{ - "type": "permission_error", "code": "MODEL_SCOPE_DENIED", "message": service.ErrModelScopeDenied.Message, - }}) - return true - } - if c.Request != nil && strings.HasSuffix(strings.TrimRight(c.Request.URL.Path, "/"), "/messages") { - h.anthropicStreamingAwareError(c, http.StatusForbidden, "permission_error", service.ErrModelScopeDenied.Message, streamStarted || c.Writer.Written()) - return true - } - h.handleStreamingAwareErrorWithCode(c, http.StatusForbidden, "permission_error", "MODEL_SCOPE_DENIED", service.ErrModelScopeDenied.Message, streamStarted || c.Writer.Written(), false) - return true -} - -func (h *GatewayHandler) handleModelScopeError(c *gin.Context, err error, streamStarted bool) bool { - if !errors.Is(err, service.ErrModelScopeDenied) { - return false - } - service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied) - if c.Request != nil && strings.HasSuffix(strings.TrimRight(c.Request.URL.Path, "/"), "/chat/completions") { - if service.StopOpenAICompactSSEKeepaliveCommitted(c) { - streamStarted = true - } - if !streamStarted && !c.Writer.Written() { - h.chatCompletionsErrorResponse(c, http.StatusForbidden, "permission_error", service.ErrModelScopeDenied.Message) - return true - } - service.MarkOpsStreamError(c, "permission_error", service.ErrModelScopeDenied.Message, http.StatusForbidden) - payload, _ := json.Marshal(gin.H{"error": gin.H{"type": "permission_error", "code": "MODEL_SCOPE_DENIED", "message": service.ErrModelScopeDenied.Message}}) - if _, writeErr := fmt.Fprintf(c.Writer, "data: %s\n\n", payload); writeErr != nil { - _ = c.Error(writeErr) - } - c.Writer.Flush() - return true - } - h.handleStreamingAwareErrorWithCode(c, http.StatusForbidden, "permission_error", "MODEL_SCOPE_DENIED", service.ErrModelScopeDenied.Message, streamStarted || c.Writer.Written()) - return true -} - -func handleGeminiModelScopeError(c *gin.Context, err error, streamStarted bool) bool { - if !errors.Is(err, service.ErrModelScopeDenied) { - return false - } - service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied) - if !streamStarted && !c.Writer.Written() { - googleError(c, http.StatusForbidden, service.ErrModelScopeDenied.Message) - return true - } - service.MarkOpsStreamError(c, "permission_error", service.ErrModelScopeDenied.Message, http.StatusForbidden) - payload, _ := json.Marshal(gin.H{"error": gin.H{"code": http.StatusForbidden, "status": "PERMISSION_DENIED", "message": service.ErrModelScopeDenied.Message}}) - if _, writeErr := fmt.Fprintf(c.Writer, "data: %s\n\n", payload); writeErr != nil { - _ = c.Error(writeErr) - } - c.Writer.Flush() - return true -} diff --git a/backend/internal/handler/gateway_model_scope_error_test.go b/backend/internal/handler/gateway_model_scope_error_test.go deleted file mode 100644 index 7a7109a..0000000 --- a/backend/internal/handler/gateway_model_scope_error_test.go +++ /dev/null @@ -1,191 +0,0 @@ -//go:build unit - -package handler - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestModelScopeExecutionErrorKeepsClientProtocol(t *testing.T) { - gin.SetMode(gin.TestMode) - openai := &OpenAIGatewayHandler{} - gateway := &GatewayHandler{} - for _, tc := range []struct { - name, path, streamPrefix string - handle func(*gin.Context, error, bool) bool - }{ - {"responses", "/v1/responses", "event: response.failed\n", openai.handleModelScopeError}, - {"chat", "/v1/chat/completions", "event: error\n", openai.handleModelScopeError}, - {"openai messages", "/v1/messages", "event: error\n", openai.handleModelScopeError}, - {"messages", "/v1/messages", "data: ", gateway.handleModelScopeError}, - {"gateway chat", "/v1/chat/completions", "data: ", gateway.handleModelScopeError}, - {"gateway responses", "/v1/responses", "event: response.failed\n", gateway.handleModelScopeError}, - {"gemini", "/v1beta/models/gemini:streamGenerateContent", "data: ", handleGeminiModelScopeError}, - } { - for _, streaming := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/stream=%v", tc.name, streaming), func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodPost, tc.path, nil) - if streaming { - c.Writer.Header().Set("Content-Type", "text/event-stream") - c.Writer.WriteHeader(http.StatusOK) - c.Writer.Flush() - } - require.True(t, tc.handle(c, fmt.Errorf("wrapped: %w", service.ErrModelScopeDenied), streaming)) - if streaming { - require.True(t, strings.HasPrefix(w.Body.String(), tc.streamPrefix), w.Body.String()) - require.Equal(t, http.StatusOK, w.Code) - } else { - require.Equal(t, http.StatusForbidden, w.Code) - } - require.Contains(t, w.Body.String(), service.ErrModelScopeDenied.Message) - require.NotContains(t, w.Body.String(), "metadata=") - require.NotContains(t, w.Body.String(), "Upstream request failed") - }) - } - } -} - -func TestModelScopeExecutionErrorDoesNotConsumeUpstreamFailure(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) - require.False(t, (&OpenAIGatewayHandler{}).handleModelScopeError(c, errors.New("upstream failed"), false)) - require.False(t, c.Writer.Written()) - require.False(t, shouldReportOpenAIWSProxyAccountFailure(fmt.Errorf("wrapped: %w", service.ErrModelScopeDenied))) - require.True(t, shouldReportOpenAIWSProxyAccountFailure(errors.New("upstream websocket failed"))) -} - -func TestModelScopeImagesJSONKeepaliveErrorPreservesDocument(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, committed := range []bool{false, true} { - t.Run(fmt.Sprintf("committed=%v", committed), func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) - interval := time.Hour - if committed { - interval = 5 * time.Millisecond - } - stop := service.StartOpenAIImagesJSONKeepalive(c, interval) - defer stop() - if committed { - require.Eventually(t, c.Writer.Written, time.Second, time.Millisecond) - } - require.True(t, (&OpenAIGatewayHandler{}).handleModelScopeError(c, service.ErrModelScopeDenied, false)) - stop() - status := http.StatusForbidden - if committed { - status = http.StatusOK // A heartbeat has already committed the HTTP status. - } - require.Equal(t, status, w.Code) - require.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) - require.True(t, json.Valid(w.Body.Bytes()), w.Body.String()) - require.NotContains(t, w.Body.String(), "event:") - require.NotContains(t, w.Body.String(), "data:") - decoder := json.NewDecoder(strings.NewReader(w.Body.String())) - var payload struct { - Error struct { - Type, Code, Message string - } - } - require.NoError(t, decoder.Decode(&payload)) - require.ErrorIs(t, decoder.Decode(&payload), io.EOF) - require.Equal(t, "permission_error", payload.Error.Type) - require.Equal(t, "MODEL_SCOPE_DENIED", payload.Error.Code) - require.Equal(t, service.ErrModelScopeDenied.Message, payload.Error.Message) - }) - } -} - -func TestModelScopeImagesJSONKeepalivePreservesCompletedDocument(t *testing.T) { - for _, name := range []string{"document", "semantic commit"} { - t.Run(name, func(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) - stop := service.StartOpenAIImagesJSONKeepalive(c, time.Hour) - defer stop() - if name == "document" { - c.JSON(http.StatusOK, gin.H{"data": []gin.H{{"b64_json": "aW1hZ2U="}}}) - } else { - service.MarkResponseCommitted(c) - require.Equal(t, -1, service.OpenAIImagesJSONKeepaliveAdjustedWrittenSize(c)) - } - completed := w.Body.String() - require.True(t, (&OpenAIGatewayHandler{}).handleModelScopeError(c, service.ErrModelScopeDenied, false)) - require.Equal(t, completed, w.Body.String(), "cannot append after meaningful output or semantic commit") - }) - } -} - -func TestModelScopeLiveErrorReturnsPermission(t *testing.T) { - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/live", nil) - (&OpenAIGatewayHandler{}).writeLiveCreateError(c, service.ErrModelScopeDenied) - require.Equal(t, http.StatusForbidden, w.Code) - require.Contains(t, w.Body.String(), "MODEL_SCOPE_DENIED") -} - -func TestModelScopeSelectionChannelDenialKeepsProtocol(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, endpoint := range []string{"responses", "chat/completions", "messages"} { - t.Run(endpoint, func(t *testing.T) { - group := &service.Group{ID: 9, Platform: service.PlatformMixed, Status: service.StatusActive, Hydrated: true, RateMultiplier: 1} - channel := service.NewChannelService(&openAIWSUsageHandlerChannelRepoStub{ - channels: []service.Channel{{ID: 1, Status: service.StatusActive, GroupIDs: []int64{group.ID}, ModelMapping: map[string]map[string]string{service.PlatformMixed: {"gpt-5.1": "qwen3"}}}}, - groupPlatforms: map[int64]string{group.ID: service.PlatformMixed}, - }, nil, nil, nil, nil) - mapped := channel.ResolveChannelMapping(context.Background(), group.ID, "gpt-5.1") - require.Equal(t, "qwen3", mapped.MappedModel) - h, upstream, key := newMixedSafetyHandler(t, group, group, service.PlatformMixed, channel) - key.ModelScope = service.ModelScopeOpenAI - key.RouteGroupIDs = []int64{group.ID} - c, rec := mixedHandlerContext(key, endpoint, `{"model":"gpt-5.1","input":"hello","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`) - switch endpoint { - case "responses": - h.Responses(c) - case "chat/completions": - h.ChatCompletions(c) - case "messages": - h.Messages(c) - } - require.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) - require.Contains(t, rec.Body.String(), "permission_error") - require.NotContains(t, rec.Body.String(), "Service temporarily unavailable") - if endpoint == "messages" { - require.Contains(t, rec.Body.String(), `"type":"error"`) - } else { - require.Contains(t, rec.Body.String(), "MODEL_SCOPE_DENIED") - } - require.Zero(t, upstream.calls, "scope denial cannot reach the upstream") - }) - } -} - -func TestModelScopeWSLaterCandidatePreservesLocalCause(t *testing.T) { - key := &service.APIKey{ModelScope: service.ModelScopeCN} - err := apiKeyModelCandidateCloseError(key, []string{"qwen3", "gpt-5"}) - require.ErrorIs(t, err, service.ErrModelScopeDenied) - require.False(t, shouldReportOpenAIWSProxyAccountFailure(err), "local later-turn refusal cannot change account scoring") - key.ModelScope = service.ModelScopeAll - key.Group = &service.Group{ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"qwen3"}}} - err = apiKeyModelCandidateCloseError(key, []string{"gpt-5"}) - require.Error(t, err) - require.NotErrorIs(t, err, service.ErrModelScopeDenied, "retain the separate group-allowlist policy") -} diff --git a/backend/internal/handler/gateway_systemone.go b/backend/internal/handler/gateway_systemone.go index f135f30..4023a44 100644 --- a/backend/internal/handler/gateway_systemone.go +++ b/backend/internal/handler/gateway_systemone.go @@ -77,11 +77,6 @@ func (h *GatewayHandler) SystemOne(c *gin.Context) { h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "System One aliases must preserve jev-latest") return } - c.Request = c.Request.WithContext(service.ContextWithModelScope(c.Request.Context(), apiKey.ModelScope)) - if err := service.CheckModelScope(c.Request.Context(), model, model); err != nil { - h.errorResponse(c, http.StatusNotFound, "not_found_error", "Model is not available for this key") - return - } // Run the existing audit gates on all native semantic content without // changing the native body delivered to the provider. auditBody, _ := json.Marshal(map[string]any{"model": model, "messages": []any{map[string]any{"role": "user", "content": string(body)}}}) diff --git a/backend/internal/handler/gateway_web_search.go b/backend/internal/handler/gateway_web_search.go index c8c56c4..231336d 100644 --- a/backend/internal/handler/gateway_web_search.go +++ b/backend/internal/handler/gateway_web_search.go @@ -165,9 +165,6 @@ func (h *GatewayHandler) WebSearch(c *gin.Context) { apiKey = routedKey } if selectErr != nil { - if h.handleModelScopeError(c, selectErr, false) { - return - } if attempt == 0 { c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{ "type": "scheduling_error", diff --git a/backend/internal/handler/gemini_catalog_target_test.go b/backend/internal/handler/gemini_catalog_target_test.go index 92f7a6c..efa4553 100644 --- a/backend/internal/handler/gemini_catalog_target_test.go +++ b/backend/internal/handler/gemini_catalog_target_test.go @@ -66,46 +66,6 @@ func newGeminiCatalogProjectionHandler(platform string, routes []service.Composi }, upstream } -func TestGeminiNativeCatalogChannelAndCompositeAliases(t *testing.T) { - for _, composite := range []bool{false, true} { - platform := service.PlatformGemini - mapping := map[string]map[string]string{platform: {"gemini-public": "gemini-live"}} - var routes []service.CompositeModelRoute - if composite { - platform = service.PlatformComposite - mapping = map[string]map[string]string{service.PlatformGemini: {"gemini-channel": "gemini-live"}, service.PlatformAntigravity: {"gemini-channel": "gemini-wrong"}} - routes = []service.CompositeModelRoute{{GroupID: 45, PublicModel: "gemini-public", UpstreamModel: "gemini-channel", TargetPlatform: service.PlatformGemini, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointGemini, Enabled: true}} - } - for _, detail := range []bool{false, true} { - h, upstream := newGeminiCatalogProjectionHandler(platform, routes, mapping) - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil) - // The actual routing middleware defaults list requests to Gemini - // before a per-model Composite target is known. - c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformGemini)) - id := int64(45) - c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &id, ModelScope: service.ModelScopeOther, Group: &service.Group{ID: id, Platform: platform, - ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-public"}}, - }}) - if detail { - c.Params = gin.Params{{Key: "model", Value: "gemini-public"}} - h.GeminiV1BetaGetModel(c) - require.Equal(t, []string{"/v1beta/models/gemini-live"}, upstream.paths) - } else { - h.GeminiV1BetaListModels(c) - require.Contains(t, rec.Body.String(), `"nextPageToken":"next"`) - require.Contains(t, rec.Body.String(), `"customEnvelope":42`) - } - require.Equal(t, 200, rec.Code, rec.Body.String()) - require.Contains(t, rec.Body.String(), `"name":"models/gemini-public"`) - require.Contains(t, rec.Body.String(), `"inputTokenLimit":123`) - require.Contains(t, rec.Body.String(), `"custom":{"retained":true}`) - require.NotContains(t, rec.Body.String(), `"name":"models/gemini-live"`) - } - } -} - func TestGeminiNativeCatalogCannotBorrowAnotherPlatformMetadata(t *testing.T) { routes := []service.CompositeModelRoute{{GroupID: 45, PublicModel: "gemini-public", UpstreamModel: "gemini-live", TargetPlatform: service.PlatformAntigravity, MatchType: service.CompositeRouteMatchExact, Enabled: true}} for _, detail := range []bool{false, true} { @@ -159,31 +119,6 @@ func TestGeminiNativeCatalogMiddlewareDefaultCannotOverrideCompositeRoute(t *tes require.NotContains(t, rec.Body.String(), "gemini-live") } -func TestGeminiNativeCatalogPrefixChecksIntermediateModelScope(t *testing.T) { - routes := []service.CompositeModelRoute{{GroupID: 45, PublicModel: "gemini-", UpstreamModel: "gpt-5.4", TargetPlatform: service.PlatformGemini, MatchType: service.CompositeRouteMatchPrefix, Enabled: true}} - mapping := map[string]map[string]string{service.PlatformGemini: {"gemini-public": "gemini-live", "gpt-5.4": "gemini-live"}} - for _, detail := range []bool{false, true} { - h, upstream := newGeminiCatalogProjectionHandler(service.PlatformComposite, routes, mapping) - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil) - id := int64(45) - c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &id, ModelScope: service.ModelScopeOther, Group: &service.Group{ID: id, Platform: service.PlatformComposite, - ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-public"}}, - }}) - if detail { - c.Params = gin.Params{{Key: "model", Value: "gemini-public"}} - h.GeminiV1BetaGetModel(c) - require.Equal(t, 404, rec.Code) - require.Empty(t, upstream.paths) - } else { - h.GeminiV1BetaListModels(c) - require.Equal(t, 200, rec.Code) - require.NotContains(t, rec.Body.String(), "gemini-public") - } - } -} - func TestGeminiNativeModelDetailDoesNotInventSuccess(t *testing.T) { for _, forced := range []bool{false, true} { groupID := int64(45) @@ -242,3 +177,43 @@ func TestGeminiNativeCatalogUsesOpenAICompatibleExplicitMappingsOnly(t *testing. require.Len(t, body.Models, 1) require.Equal(t, "models/gemini-live", body.Models[0].Name) } + +func TestGeminiNativeCatalogChannelAndCompositeAliases(t *testing.T) { + for _, composite := range []bool{false, true} { + platform := service.PlatformGemini + mapping := map[string]map[string]string{platform: {"gemini-public": "gemini-live"}} + var routes []service.CompositeModelRoute + if composite { + platform = service.PlatformComposite + mapping = map[string]map[string]string{service.PlatformGemini: {"gemini-channel": "gemini-live"}, service.PlatformAntigravity: {"gemini-channel": "gemini-wrong"}} + routes = []service.CompositeModelRoute{{GroupID: 45, PublicModel: "gemini-public", UpstreamModel: "gemini-channel", TargetPlatform: service.PlatformGemini, MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointGemini, Enabled: true}} + } + for _, detail := range []bool{false, true} { + h, upstream := newGeminiCatalogProjectionHandler(platform, routes, mapping) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil) + // The actual routing middleware defaults list requests to Gemini + // before a per-model Composite target is known. + c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformGemini)) + id := int64(45) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &id, ModelScope: service.ModelScopeAll, Group: &service.Group{ID: id, Platform: platform, + ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-public"}}, + }}) + if detail { + c.Params = gin.Params{{Key: "model", Value: "gemini-public"}} + h.GeminiV1BetaGetModel(c) + require.Equal(t, []string{"/v1beta/models/gemini-live"}, upstream.paths) + } else { + h.GeminiV1BetaListModels(c) + require.Contains(t, rec.Body.String(), `"nextPageToken":"next"`) + require.Contains(t, rec.Body.String(), `"customEnvelope":42`) + } + require.Equal(t, 200, rec.Code, rec.Body.String()) + require.Contains(t, rec.Body.String(), `"name":"models/gemini-public"`) + require.Contains(t, rec.Body.String(), `"inputTokenLimit":123`) + require.Contains(t, rec.Body.String(), `"custom":{"retained":true}`) + require.NotContains(t, rec.Body.String(), `"name":"models/gemini-live"`) + } + } +} diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index f8a37aa..b2776b3 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -68,7 +68,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) { googleError(c, http.StatusBadRequest, "API key group platform is not gemini") return } - ctx := service.ContextWithModelScope(c.Request.Context(), apiKey.ModelScope) + ctx := c.Request.Context() localModels := make([]gemini.Model, 0) var response, failure *service.UpstreamHTTPResult for _, groupID := range geminiStudioGroupIDs(apiKey) { @@ -153,10 +153,7 @@ func (h *GatewayHandler) filterGeminiIDsForGroup(ctx context.Context, apiKey *se if group == nil { return nil // A cold group-policy projection cannot establish its allowlist. } - allowed := service.FilterModelScope(apiKey.ModelScope, ids) - if h.gatewayService != nil { - allowed = h.gatewayService.FilterModelsForKeyScope(ctx, &groupID, apiKey.ModelScope, allowed, service.CompositeRouteEndpointGemini) - } + allowed := ids filtered := make([]string, 0, len(allowed)) for _, id := range allowed { if !group.ModelAllowlistEnabled() || group.ModelAllowlist.Allows(id) { @@ -203,7 +200,7 @@ func (h *GatewayHandler) filterGeminiCatalogForGroup(ctx context.Context, apiKey continue } routeCtx, target, ok := h.gatewayService.GeminiCatalogRoute(ctx, group, public) - if !ok || len(service.FilterModelScope(apiKey.ModelScope, []string{target})) == 0 { + if !ok { continue } if platform, resolved := service.ResolvedTargetPlatformFromContext(routeCtx); resolved && platform != catalogPlatform { @@ -386,7 +383,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { googleError(c, http.StatusBadRequest, "Invalid model in URL") return } - ctx := service.ContextWithModelScope(c.Request.Context(), apiKey.ModelScope) + ctx := c.Request.Context() var failure *service.UpstreamHTTPResult sawTransportError := false for _, groupID := range geminiStudioGroupIDs(apiKey) { @@ -395,7 +392,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) { } routeCtx, target, targetOK := h.gatewayService.GeminiCatalogRoute(ctx, h.groupForModelsList(ctx, apiKey, groupID), publicModel) model = target - if !targetOK || len(service.FilterModelScope(apiKey.ModelScope, []string{model})) == 0 { + if !targetOK { continue } platform, resolved := service.ResolvedTargetPlatformFromContext(routeCtx) @@ -744,9 +741,6 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { modelName = openAIChannelForwardModel(channelMapping, reqModel) } if err != nil { - if handleGeminiModelScopeError(c, err, streamStarted) { - return - } if len(fs.FailedAccountIDs) == 0 { diagPlatform := service.PlatformGemini if p := effectiveAPIKeyPlatform(c, apiKey); p == service.PlatformAntigravity { @@ -928,9 +922,6 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { }) } if err != nil { - if handleGeminiModelScopeError(c, err, streamStarted) { - return - } h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), apiKey.GroupID, sessionKey, account, err) var failoverErr *service.UpstreamFailoverError if result == nil && errors.As(err, &failoverErr) { diff --git a/backend/internal/handler/grok_audio.go b/backend/internal/handler/grok_audio.go index be5ba34..45bb975 100644 --- a/backend/internal/handler/grok_audio.go +++ b/backend/internal/handler/grok_audio.go @@ -50,10 +50,6 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { if strings.TrimSpace(model) == "" { model = "grok-voice-latest" } - if !apiKey.AllowsModelScope(model) { - h.handleModelScopeError(c, service.ErrModelScopeDenied, false) - return - } // 余额模式在途预留(与计费同口径估算;计费任务扣减余额缓存后才释放)。 inflightDone, inflightErr := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, service.InflightEstimateRequest{Model: model, Kind: service.InflightEstimateAudio, AudioMode: "realtime", AudioUnits: 1}) if inflightErr != nil { @@ -98,9 +94,6 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { apiKey = routedKey } if selectErr != nil || candidate == nil || candidate.Account == nil { - if h.handleModelScopeError(c, selectErr, false) { - return - } break } candidateSeen = true @@ -126,11 +119,6 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) { candidateUpstream, openErr := h.gatewayService.OpenGrokRealtime(probeCtx, account, token, model) cancelProbe() if openErr != nil { - if errors.Is(openErr, service.ErrModelScopeDenied) { - release() - h.handleModelScopeError(c, openErr, false) - return - } reqLog.Warn("grok_realtime.pre_accept_failed", zap.Int64("account_id", account.ID), zap.Error(openErr)) statusCode := http.StatusBadGateway var dialErr *service.GrokRealtimeDialError @@ -297,9 +285,6 @@ func (h *OpenAIGatewayHandler) GrokVoice(c *gin.Context, endpoint string) { apiKey = routedKey } if selectErr != nil || selection == nil || selection.Account == nil { - if h.handleModelScopeError(c, selectErr, false) { - return - } if last != nil { h.handleFailoverExhausted(c, last, false) } else if accountSlotBusySeen { diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go index cd46da9..8f76bd0 100644 --- a/backend/internal/handler/grok_media.go +++ b/backend/internal/handler/grok_media.go @@ -370,9 +370,6 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. accountReleaseFunc = selection.ReleaseFunc } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } if failoverClientGone(c) { reqLog.Info("grok_media.account_select_aborted_client_disconnected", zap.Error(err)) return @@ -594,15 +591,6 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service. service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, responseLatencyMs) if err != nil { - if errors.Is(err, service.ErrModelScopeDenied) { - if createJob != nil { - if refundErr := h.gatewayService.FailGrokVideoBillingJob(requestCtx, createJob); refundErr != nil { - reqLog.Error("grok_media.refund_failed", zap.String("job_id", createJob.ID), zap.Error(refundErr)) - } - } - h.handleModelScopeError(c, err, streamStarted) - return - } if endpoint.IsGenerationRequest() { h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), apiKey.GroupID, sessionHash, account, err) } diff --git a/backend/internal/handler/model_plaza_handler.go b/backend/internal/handler/model_plaza_handler.go index ae06c5d..128cf61 100644 --- a/backend/internal/handler/model_plaza_handler.go +++ b/backend/internal/handler/model_plaza_handler.go @@ -69,9 +69,6 @@ type modelPlazaTimePricing struct { type modelPlazaModel struct { Name string `json:"name"` Platform string `json:"platform"` - RateMultiplier *float64 `json:"rate_multiplier,omitempty"` - ModelVendor string `json:"model_vendor,omitempty"` - BillingModel string `json:"billing_model,omitempty"` Pricing *userSupportedModelPricing `json:"pricing"` OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"` // LongContextBasis 多档时的计价基准:"whole_request"(整单按档)| "marginal"(仅超出部分)。 @@ -223,9 +220,6 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64, qu models = append(models, modelPlazaModel{ Name: m.Name, Platform: m.Platform, - RateMultiplier: m.RateMultiplier, - ModelVendor: m.ModelVendor, - BillingModel: m.BillingModel, Pricing: toUserPricing(m.Pricing), OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing), LongContextBasis: string(m.LongContextBasis), diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go index b4078ac..4529bcc 100644 --- a/backend/internal/handler/openai_alpha_search.go +++ b/backend/internal/handler/openai_alpha_search.go @@ -189,9 +189,6 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { } } if err != nil || selection == nil || selection.Account == nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } if failoverClientGone(c) { reqLog.Info("openai_alpha_search.account_select_aborted_client_disconnected", zap.Error(err)) return @@ -244,9 +241,6 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) { return } - if h.handleModelScopeError(c, err, false) { - return - } h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), apiKey.GroupID, sessionHash, account, err) var failoverErr *service.UpstreamFailoverError if !errors.As(err, &failoverErr) { diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 1446e5d..112064c 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -245,9 +245,6 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { apiKey = routedKey } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } if failoverClientGone(c) { reqLog.Info("openai_chat_completions.account_select_aborted_client_disconnected", zap.Error(err)) return @@ -388,12 +385,6 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { }) } if err != nil { - if service.IsMixedLocalRequestError(err) { - return - } - if h.handleModelScopeError(c, err, streamStarted) { - return - } h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), apiKey.GroupID, sessionHash, account, err) if result != nil && result.ImageCount > 0 { reqLog.Warn("openai_chat_completions.forward_partial_error_with_image_result", diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index b79f54b..5f3304c 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -38,14 +38,6 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { } ifNoneMatch := c.GetHeader("If-None-Match") - var discoveryAccount *service.Account - filterScope := func(models []string) []string { - models = h.gatewayService.FilterModelsForKeyScope(c.Request.Context(), apiKey.GroupID, apiKey.ModelScope, models) - return service.FilterModelsForAccountScope(apiKey.ModelScope, models, discoveryAccount) - } - if service.NormalizeModelScope(apiKey.ModelScope) != service.ModelScopeAll { - ifNoneMatch = "" - } // 固定账号分支:开启后只用选定账号拉取 manifest,不经过调度器; // 全部不可用/全部失败时按 FallbackToScheduler 决定回退调度器或返回错误。 if apiKey.Group.Platform == service.PlatformOpenAI && @@ -69,7 +61,6 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { } // 回退开启:跌入下方调度器循环。 } else { - discoveryAccount = pinnedAccount // 让 ops 错误日志携带实际拉取成功的首个固定账号。 setOpsSelectedAccount(c, pinnedAccount.ID, pinnedAccount.Platform) if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, pinnedManifest, ifNoneMatch); err != nil { @@ -79,7 +70,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { if c.Request.Context().Err() != nil { return } - writeOpenAIModelsResponse(c, pinnedManifest, filterScope) + writeOpenAIModelsResponse(c, pinnedManifest) return } } @@ -98,7 +89,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { return } if configured { - writeOpenAIModelsResponse(c, configuredManifest, filterScope) + writeOpenAIModelsResponse(c, configuredManifest) return } } @@ -126,7 +117,6 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { } // 让 ops 错误日志携带实际选中的上游账号,便于定位失效账号(#4544)。 setOpsSelectedAccount(c, account.ID, account.Platform) - discoveryAccount = account // The client ETag represents the final group-specific body, so fetch the // source manifest before applying local filtering and alias metadata. @@ -160,7 +150,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { return } - writeOpenAIModelsResponse(c, manifest, filterScope) + writeOpenAIModelsResponse(c, manifest) return } } diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go index 0a5bf8d..5062c33 100644 --- a/backend/internal/handler/openai_embeddings.go +++ b/backend/internal/handler/openai_embeddings.go @@ -186,9 +186,6 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { apiKey = routedKey } if err != nil { - if h.handleModelScopeError(c, err, false) { - return - } if failoverClientGone(c) { reqLog.Info("openai_embeddings.account_select_aborted_client_disconnected", zap.Error(err)) return @@ -264,9 +261,6 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) { service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, responseLatencyMs) if err != nil { - if h.handleModelScopeError(c, err, false) { - return - } h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), apiKey.GroupID, "", account, err) var failoverErr *service.UpstreamFailoverError if errors.As(err, &failoverErr) { diff --git a/backend/internal/handler/openai_gateway_cn_dispatch_test.go b/backend/internal/handler/openai_gateway_cn_dispatch_test.go index e76364a..914cc0c 100644 --- a/backend/internal/handler/openai_gateway_cn_dispatch_test.go +++ b/backend/internal/handler/openai_gateway_cn_dispatch_test.go @@ -50,17 +50,6 @@ func TestAllowOpenAICompatibleMessagesDispatch_OpenAIGroupResolvedGrok(t *testin "Claude Code 打 grok-* 时,OpenAI 平台的 Grok 分组不能再 403") } -func TestMixedMessagesDispatchRequiresActualMixedGroup(t *testing.T) { - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest("POST", "/v1/messages", nil) - c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformMixed)) - for _, platform := range []string{service.PlatformMixed, service.PlatformComposite, service.PlatformOpenAI} { - key := &service.APIKey{Group: &service.Group{Platform: platform}} - require.Equal(t, platform == service.PlatformMixed, allowOpenAICompatibleMessagesDispatch(c, key), platform) - require.Equal(t, platform == service.PlatformMixed, service.CrossProtocolConversionAllowed(key), platform) - } -} - func TestAllowOpenAICompatibleMessagesDispatch_CompositeResolvedTargets(t *testing.T) { gin.SetMode(gin.TestMode) @@ -119,3 +108,27 @@ func TestAllowOpenAICompatibleMessagesDispatch_SmartRoutingResolvedOpenAI(t *tes require.False(t, allowOpenAICompatibleMessagesDispatch(c, apiKey), "未解析到后续分组时,主分组开关关掉就拒绝") } + +func TestNativeProviderCompositeMessagesPreservesModel(t *testing.T) { + for _, platform := range []string{service.PlatformCline, service.PlatformCommandCode} { + t.Run(platform, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/messages", nil) + c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), platform)) + key := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}} + require.True(t, allowOpenAICompatibleMessagesDispatch(c, key)) + require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, key, "claude-opus-4-6")) + }) + } +} + +func TestNativeProviderMessagesCandidateDoesNotBorrowTargetPermission(t *testing.T) { + for _, platform := range []string{service.PlatformCline, service.PlatformCommandCode} { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/v1/messages", nil) + c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), platform)) + primary := &service.Group{ID: 1, Platform: service.PlatformOpenAI} + key := &service.APIKey{GroupID: &primary.ID, Group: primary, RouteGroupIDs: []int64{1, 2}} + require.False(t, allowOpenAICompatibleMessagesDispatch(c, key), platform) + } +} diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 3c297d3..4fb8278 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -76,7 +76,7 @@ func newOpenAIWSUnsupportedModelSwitchError(model string) error { } func shouldReportOpenAIWSProxyAccountFailure(err error) bool { - return err != nil && !errors.Is(err, service.ErrModelScopeDenied) && !errors.Is(err, errOpenAIWSUnsupportedModelSwitch) && !service.IsOpenAIWSSessionPreemptedError(err) + return err != nil && !errors.Is(err, errOpenAIWSUnsupportedModelSwitch) && !service.IsOpenAIWSSessionPreemptedError(err) } // openAIWSIngressEndedByClient reports whether a finished ingress WebSocket turn @@ -203,11 +203,11 @@ func resolveOpenAIMessagesDispatchMappedModel(c *gin.Context, apiKey *service.AP if apiKey == nil || apiKey.Group == nil { return "" } - // composite 解析到 grok/CN 目标时调度级映射不适用(Group 级映射的 gpt-5.x + // composite 解析到 grok/多协议供应商目标时调度级映射不适用(Group 级映射的 gpt-5.x // 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。 if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil { if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok && - (platform == service.PlatformMixed || platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) { + (platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) { return "" } } @@ -328,7 +328,7 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe return true } // grok/CN 仍豁免。其余分组关掉「允许 messages 分发」就 403。 - if apiKey.Group.Platform == service.PlatformMixed || messagesDispatchExemptPlatform(apiKey.Group.Platform) { + if messagesDispatchExemptPlatform(apiKey.Group.Platform) { return true } if apiKey.Group.IsGrokMessagesDispatchGroup() { @@ -337,7 +337,7 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe resolved, resolvedOK := resolvedMessagesDispatchPlatform(c) // OpenAI 平台但名叫 Grok 的分组、以及 Claude Code 把模型写成 grok-* 时, // 路由已把目标解析成 grok/CN,不能再被 openai 分组的 allow_messages_dispatch 开关拦住。 - if resolvedOK && messagesDispatchExemptPlatform(resolved) { + if resolvedOK && (apiKey.Group.Platform == service.PlatformComposite || len(apiKey.CandidateGroupIDs()) <= 1) && messagesDispatchExemptPlatform(resolved) { return true } if apiKey.Group.Platform == service.PlatformComposite { @@ -362,7 +362,8 @@ func allowInitialOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *servic func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool { return compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformOpenAI, service.PlatformGrok, - service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformMixed, service.PlatformCommandCode, service.PlatformCline) + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, + service.PlatformCline, service.PlatformCommandCode) } // isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket @@ -783,9 +784,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { } } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } if failoverClientGone(c) { reqLog.Info("openai.account_select_aborted_client_disconnected", zap.Error(err)) return @@ -968,12 +966,6 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { }) } if err != nil { - if service.IsMixedLocalRequestError(err) { - return - } - if h.handleModelScopeError(c, err, streamStarted) { - return - } h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), apiKey.GroupID, sessionHash, account, err) if result != nil && result.ClientDisconnect { reqLog.Info("openai.client_disconnected", @@ -1350,11 +1342,6 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { bindOpenAIReasoningEffortPolicyForMessagesRequest(c, apiKey, body) service.CaptureMessagesValidationFacts(c, body) routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel) - if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformMixed { - routingModel = reqModel - } else if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok && platform == service.PlatformMixed { - routingModel = reqModel - } preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(c, apiKey, reqModel) reqStream := gjson.GetBytes(body, "stream").Bool() @@ -1511,9 +1498,6 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { } } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } if failoverClientGone(c) { reqLog.Info("openai_messages.account_select_aborted_client_disconnected", zap.Error(err)) return @@ -1657,12 +1641,6 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { }) } if err != nil { - if service.IsMixedLocalRequestError(err) { - return - } - if h.handleModelScopeError(c, err, streamStarted) { - return - } h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), apiKey.GroupID, sessionHash, account, err) if result != nil && result.ImageCount > 0 { reqLog.Warn("openai_messages.forward_partial_error_with_image_result", @@ -2693,11 +2671,6 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { wsForwardModel = openAIChannelForwardModel(channelMappingWS, reqModel) } if err != nil { - if errors.Is(err, service.ErrModelScopeDenied) { - service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied) - closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ErrModelScopeDenied.Message) - return - } reqLog.Warn("openai.websocket_account_select_failed", zap.Error(openAICompatibleSelectionErrorForLog(err, requestPlatform)), zap.Int("excluded_account_count", len(failedAccountIDs)), @@ -2934,9 +2907,6 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } setOpsRequestContext(c, model, true) mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, model) - if !apiKey.AllowsModelScope(model) || !apiKey.AllowsModelScope(mapping.MappedModel) { - return "", service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, service.ErrModelScopeDenied.Message, service.ErrModelScopeDenied) - } freshAccount, freshOK := h.gatewayService.RefreshSchedulerAccountFreshness(ctx, account, mapping.MappedModel) if !freshOK { return "", service.NewOpenAIWSClientCloseError(coderws.StatusTryAgainLater, "account is no longer schedulable for this model, please reconnect", nil) @@ -3237,11 +3207,6 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { } var closeErr *service.OpenAIWSClientCloseError - if errors.Is(err, service.ErrModelScopeDenied) { - service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied) - closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ErrModelScopeDenied.Message) - return - } hasClientCloseErr := errors.As(err, &closeErr) if openAIWSIngressEndedByClient(err) { closedFields := []zap.Field{zap.Int64("account_id", account.ID)} @@ -3958,29 +3923,6 @@ func blockedModelAllowlistCandidate(group *service.Group, candidates []string) s return "" } -func blockedAPIKeyModelCandidate(key *service.APIKey, candidates []string) string { - if key == nil { - return "" - } - for _, model := range candidates { - if !key.AllowsModelScope(model) { - return model - } - } - return blockedModelAllowlistCandidate(key.Group, candidates) -} - -func apiKeyModelCandidateCloseError(key *service.APIKey, candidates []string) error { - blocked := blockedAPIKeyModelCandidate(key, candidates) - if blocked == "" { - return nil - } - if !key.AllowsModelScope(blocked) { - return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, service.ErrModelScopeDenied.Message, service.ErrModelScopeDenied) - } - return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked), nil) -} - func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason string) { if conn == nil { return @@ -4537,3 +4479,18 @@ func summarizeWSCloseErrorForLog(err error) (string, string) { } return closeStatus, closeReason } + +func blockedAPIKeyModelCandidate(key *service.APIKey, candidates []string) string { + if key == nil { + return "" + } + return blockedModelAllowlistCandidate(key.Group, candidates) +} + +func apiKeyModelCandidateCloseError(key *service.APIKey, candidates []string) error { + blocked := blockedAPIKeyModelCandidate(key, candidates) + if blocked == "" { + return nil + } + return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked), nil) +} diff --git a/backend/internal/handler/openai_gateway_mixed_safety_test.go b/backend/internal/handler/openai_gateway_mixed_safety_test.go deleted file mode 100644 index 535be72..0000000 --- a/backend/internal/handler/openai_gateway_mixed_safety_test.go +++ /dev/null @@ -1,122 +0,0 @@ -//go:build unit - -package handler - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" - middleware "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 mixedHandlerUpstream struct { - service.HTTPUpstream - calls int - groupID int64 -} - -func (u *mixedHandlerUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) { - u.calls++ - if group, ok := req.Context().Value(ctxkey.Group).(*service.Group); ok { - u.groupID = group.ID - } - if strings.HasSuffix(req.URL.Path, "/messages") { - return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"msg_mixed","type":"message","role":"assistant","model":"gpt-5.1","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":2}}`))}, nil - } - return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"cc_mixed","model":"gpt-5.1","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":2}}`))}, nil -} - -func newMixedSafetyHandler(t *testing.T, primary, backup *service.Group, platform string, channels ...*service.ChannelService) (*OpenAIGatewayHandler, *mixedHandlerUpstream, *service.APIKey) { - t.Helper() - account := service.Account{ID: 1, Platform: platform, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, GroupIDs: []int64{backup.ID}, Credentials: map[string]any{"api_key": "test", "base_url": "https://upstream.invalid/v1", "model_mapping": map[string]any{"gpt-5.1": "gpt-5.1"}}} - cfg := &config.Config{RunMode: config.RunModeStandard} - cfg.Default.RateMultiplier = 1 - accountRepo := openAIImagesFailoverAccountRepo{accounts: []service.Account{account}} - snapshot := service.NewSchedulerSnapshotService(&smartRouteHandlerSnapshot{fakeSchedulerCache{accounts: []*service.Account{&account}}}, nil, accountRepo, smartRouteHandlerGroups{groups: map[int64]*service.Group{primary.ID: primary, backup.ID: backup}}, cfg) - for _, id := range []int64{primary.ID, backup.ID} { - _, err := snapshot.GetGroupByIDLite(context.Background(), id) - require.NoError(t, err) - } - billingCache := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil) - t.Cleanup(billingCache.Stop) - upstream := &mixedHandlerUpstream{} - usage := smartRouteHandlerUsage{logs: make(chan *service.UsageLog, 5)} - var channel *service.ChannelService - if len(channels) > 0 { - channel = channels[0] - } - gateway := service.NewOpenAIGatewayService(accountRepo, usage, smartRouteHandlerBilling{}, nil, nil, nil, nil, cfg, snapshot, nil, service.NewBillingService(cfg, nil), nil, billingCache, upstream, &service.DeferredService{}, nil, nil, nil, channel, nil, nil, nil) - h := NewOpenAIGatewayHandler(gateway, service.NewConcurrencyService(nil), billingCache, &service.APIKeyService{}, nil, nil, nil, nil, cfg) - key := &service.APIKey{ID: 7, UserID: 42, User: &service.User{ID: 42, Status: service.StatusActive}, GroupID: &primary.ID, Group: primary, RouteGroupIDs: []int64{primary.ID, backup.ID}} - return h, upstream, key -} - -func mixedHandlerContext(key *service.APIKey, endpoint, body string) (*gin.Context, *httptest.ResponseRecorder) { - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - ctx := context.WithValue(context.Background(), ctxkey.Group, key.Group) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/"+endpoint, strings.NewReader(body)).WithContext(ctx) - c.Request.Header.Set("Content-Type", "application/json") - c.Set(string(middleware.ContextKeyAPIKey), key) - c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 42, Concurrency: 0}) - return c, rec -} - -func TestMixedMessagesSmartKeyUsesCandidatePermissions(t *testing.T) { - for _, platform := range []string{service.PlatformMixed, service.PlatformOpenAI, service.PlatformComposite} { - t.Run(platform, func(t *testing.T) { - primary := &service.Group{ID: 9, Platform: service.PlatformOpenAI, Status: service.StatusActive, Hydrated: true, RateMultiplier: 1} - backup := &service.Group{ID: 17, Platform: platform, Status: service.StatusActive, Hydrated: true, RateMultiplier: 1, CrossProtocolConversionEnabled: true} - accountPlatform := platform - if platform == service.PlatformComposite { - accountPlatform = service.PlatformMixed - } - h, upstream, key := newMixedSafetyHandler(t, primary, backup, accountPlatform) - c, rec := mixedHandlerContext(key, "messages", `{"model":"gpt-5.1","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`) - c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformMixed)) - require.True(t, allowInitialOpenAICompatibleMessagesDispatch(c, key)) - h.Messages(c) - if platform == service.PlatformMixed { - require.Equal(t, 200, rec.Code, rec.Body.String()) - require.Contains(t, rec.Body.String(), "hello") - require.Equal(t, 1, upstream.calls) - require.Equal(t, backup.ID, upstream.groupID) - } else { - require.GreaterOrEqual(t, rec.Code, 400, rec.Body.String()) - require.Zero(t, upstream.calls, "resolved Mixed must not bypass an old candidate's switch") - } - }) - } -} - -func TestMixedLocalLimitationsPreserveHandler400(t *testing.T) { - for _, endpoint := range []string{"responses", "chat/completions", "messages"} { - t.Run(endpoint, func(t *testing.T) { - group := &service.Group{ID: 9, Platform: service.PlatformMixed, Status: service.StatusActive, Hydrated: true, RateMultiplier: 1} - h, upstream, key := newMixedSafetyHandler(t, group, group, service.PlatformMixed) - key.RouteGroupIDs = []int64{group.ID} - body := `{"model":"gpt-5.1","audio":{},"max_tokens":64,"messages":[{"role":"user","content":"hello"}],"input":"hello"}` - c, rec := mixedHandlerContext(key, endpoint, body) - switch endpoint { - case "responses": - h.Responses(c) - case "chat/completions": - h.ChatCompletions(c) - case "messages": - h.Messages(c) - } - require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) - require.Contains(t, rec.Body.String(), `"invalid_request_error"`) - require.Zero(t, upstream.calls) - }) - } -} diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index 375f1fb..2498e72 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -223,9 +223,6 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { requestCtx = service.WithOpenAIImagesEndpoint(service.WithOpenAIImageGenerationIntent(c.Request.Context())) } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } if failoverClientGone(c) { reqLog.Info("openai.images.account_select_aborted_client_disconnected", zap.Error(err)) return @@ -321,9 +318,6 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs)) } if err != nil { - if h.handleModelScopeError(c, err, streamStarted) { - return - } h.gatewayService.InvalidateUpstreamAffinity(c.Request.Context(), apiKey.GroupID, sessionHash, account, err) if result != nil && result.ImageCount > 0 { reqLog.Warn("openai.images.forward_partial_error_with_image_result", diff --git a/backend/internal/handler/openai_live.go b/backend/internal/handler/openai_live.go index 1fd0d95..ce6ec4a 100644 --- a/backend/internal/handler/openai_live.go +++ b/backend/internal/handler/openai_live.go @@ -187,8 +187,6 @@ func liveCallIdentity( func (h *OpenAIGatewayHandler) writeLiveCreateError(c *gin.Context, err error) { switch { - case h.handleModelScopeError(c, err, false): - return case errors.Is(err, service.ErrLiveBillingNotConfigured): h.writeLiveBillingNotConfigured(c) case errors.Is(err, service.ErrLiveIdentityMismatch): @@ -246,9 +244,6 @@ func (h *OpenAIGatewayHandler) LiveSideband(c *gin.Context) { c.Request = c.Request.WithContext(service.ContextWithAPIKeyRoute(c.Request.Context(), apiKey)) record, err := h.gatewayService.GetLiveCallForIdentity(c.Request.Context(), c.Param("call_id"), identity) if err != nil { - if h.handleModelScopeError(c, err, false) { - return - } if errors.Is(err, service.ErrLiveIdentityMismatch) { h.errorResponse(c, http.StatusForbidden, "permission_error", "Live call belongs to another identity") return diff --git a/backend/internal/handler/openai_live_test.go b/backend/internal/handler/openai_live_test.go index ae75dcd..0e705ae 100644 --- a/backend/internal/handler/openai_live_test.go +++ b/backend/internal/handler/openai_live_test.go @@ -2,7 +2,6 @@ package handler import ( "bytes" - "context" "encoding/json" "mime/multipart" "net/http" @@ -145,44 +144,6 @@ func TestLiveCreateIdentityMismatchIsForbidden(t *testing.T) { require.Equal(t, "permission_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String()) } -type liveSidebandScopeCache struct { - service.GatewayCache - service.LiveCallStore - record *service.LiveCallRecord -} - -func (s *liveSidebandScopeCache) GetLiveCall(context.Context, string) (*service.LiveCallRecord, error) { - return s.record, nil -} - -func TestLiveSidebandScopeNarrowingIsForbiddenBeforeUpgrade(t *testing.T) { - gin.SetMode(gin.TestMode) - groupID := int64(44) - cache := &liveSidebandScopeCache{record: &service.LiveCallRecord{ - CallID: "call_narrowed", APIKeyID: 22, UserID: 33, GroupID: groupID, - Model: "gpt-live", Controller: service.LiveControllerPending, - }} - gateway := service.NewOpenAIGatewayService( - nil, nil, nil, nil, nil, nil, cache, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - ) - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodGet, "/v1/live/call_narrowed", nil) - c.Request.Header.Set("Connection", "Upgrade") - c.Request.Header.Set("Upgrade", "websocket") - c.Request.Header.Set("Sec-WebSocket-Version", "13") - c.Request.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") - c.Params = gin.Params{{Key: "call_id", Value: cache.record.CallID}} - key := &service.APIKey{ID: 22, UserID: 33, GroupID: &groupID, ModelScope: service.ModelScopeCN, - Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, AllowLive: true}} - c.Set(string(middleware2.ContextKeyAPIKey), key) - c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: key.UserID}) - (&OpenAIGatewayHandler{gatewayService: gateway}).LiveSideband(c) - require.Equal(t, http.StatusForbidden, recorder.Code) - require.Equal(t, "MODEL_SCOPE_DENIED", gjson.GetBytes(recorder.Body.Bytes(), "error.code").String()) - require.Empty(t, recorder.Header().Get("Upgrade")) -} - func TestLiveStandardModeRejectsBeforeBillingOrUpstream(t *testing.T) { gin.SetMode(gin.TestMode) cfg := &config.Config{RunMode: config.RunModeStandard} diff --git a/backend/internal/handler/openai_models_handler.go b/backend/internal/handler/openai_models_handler.go index 9daf200..8df65f9 100644 --- a/backend/internal/handler/openai_models_handler.go +++ b/backend/internal/handler/openai_models_handler.go @@ -7,7 +7,6 @@ import ( "net/http" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" - middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) @@ -21,9 +20,6 @@ func (h *GatewayHandler) pinnedOpenAIModels(c *gin.Context, group *service.Group return } etag := c.GetHeader("If-None-Match") - if key, ok := middleware2.GetAPIKeyFromContext(c); ok && service.NormalizeModelScope(key.ModelScope) != service.ModelScopeAll { - etag = "" - } if c.Param("model") != "" { etag = "" // A collection ETag cannot validate a single-model representation. } @@ -42,31 +38,14 @@ func (h *GatewayHandler) pinnedOpenAIModels(c *gin.Context, group *service.Group return } setOpsSelectedAccount(c, account.ID, account.Platform) - writeOpenAIModelsResponse(c, response, func(models []string) []string { - key, _ := middleware2.GetAPIKeyFromContext(c) - models = h.openAIGatewayService.FilterModelsForKeyScope(c.Request.Context(), &group.ID, key.ModelScope, models) - return service.FilterModelsForAccountScope(key.ModelScope, models, account) - }) + 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, filters ...func([]string) []string) { - if key, ok := middleware2.GetAPIKeyFromContext(c); ok && service.NormalizeModelScope(key.ModelScope) != service.ModelScopeAll { - body, err := filterModelCatalogueForScope(manifest.Body, key.ModelScope, filters...) - if err != nil { - writeOpenAIModelsError(c, http.StatusBadGateway, "upstream_error", "Invalid model catalogue") - return - } - // Never mutate the shared manifest cache or reuse its unfiltered ETag. - copyManifest := *manifest - copyManifest.Body = body - copyManifest.ETag = service.CodexModelsManifestETag(body) - copyManifest.NotModified = c.Param("model") == "" && service.CodexModelsManifestETagMatches(c.GetHeader("If-None-Match"), copyManifest.ETag) - manifest = ©Manifest - } +func writeOpenAIModelsResponse(c *gin.Context, manifest *service.OpenAIModelsResponse) { if c.Param("model") != "" { writeRetrievedModel(c, manifest.Body) return @@ -99,10 +78,6 @@ func writeModelsListResponse(c *gin.Context, models any) { } func writeRetrievedModel(c *gin.Context, body []byte) { - if key, ok := middleware2.GetAPIKeyFromContext(c); ok && !key.AllowsModelScope(c.Param("model")) { - writeOpenAIModelsError(c, http.StatusNotFound, "model_not_found", "Model is not available for this API key") - return - } var catalog struct { Data []json.RawMessage `json:"data"` } @@ -132,64 +107,3 @@ func writeRetrievedModel(c *gin.Context, body []byte) { "message": fmt.Sprintf("Model %q does not exist or is not available for this group", modelID), }}) } - -// Preserve metadata but filter every supported catalogue shape on a private -// JSON value. Wildcards are configuration patterns, never listable models. -func filterModelCatalogueForScope(body []byte, scope string, filters ...func([]string) []string) ([]byte, error) { - if service.NormalizeModelScope(scope) == service.ModelScopeAll { - return body, nil - } - var envelope map[string]json.RawMessage - if err := json.Unmarshal(body, &envelope); err != nil { - return nil, err - } - for _, field := range []string{"data", "models"} { - raw, exists := envelope[field] - if !exists { - continue - } - var entries []json.RawMessage - if err := json.Unmarshal(raw, &entries); err != nil { - return nil, err - } - ids := make([]string, len(entries)) - for i, entry := range entries { - var model struct { - ID string `json:"id"` - Slug string `json:"slug"` - Name string `json:"name"` - } - if err := json.Unmarshal(entry, &model); err != nil { - return nil, err - } - id := model.ID - if id == "" { - id = model.Slug - } - if id == "" { - id = model.Name - } - ids[i] = id - } - allowed := service.FilterModelScope(scope, ids) - for _, filter := range filters { - allowed = filter(allowed) - } - allowedIDs := make(map[string]bool, len(allowed)) - for _, id := range allowed { - allowedIDs[id] = true - } - kept := make([]json.RawMessage, 0, len(entries)) - for i, entry := range entries { - if allowedIDs[ids[i]] { - kept = append(kept, entry) - } - } - filtered, err := json.Marshal(kept) - if err != nil { - return nil, err - } - envelope[field] = filtered - } - return json.Marshal(envelope) -} diff --git a/backend/internal/pkg/ctxkey/ctxkey.go b/backend/internal/pkg/ctxkey/ctxkey.go index 9e1aea8..c34ef58 100644 --- a/backend/internal/pkg/ctxkey/ctxkey.go +++ b/backend/internal/pkg/ctxkey/ctxkey.go @@ -5,8 +5,6 @@ package ctxkey type Key string const ( - // APIKeyModelScope is the credential's provider category, independent of routing. - APIKeyModelScope Key = "ctx_api_key_model_scope" // ForcePlatform 强制平台(用于 /antigravity 路由),由 middleware.ForcePlatform 设置 ForcePlatform Key = "ctx_force_platform" diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index e4aa107..e49f1f5 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -1073,7 +1073,7 @@ func groupEntityToService(g *dbent.Group) *service.Group { I18n: g.I18n, Platform: g.Platform, RateMultiplier: g.RateMultiplier, - MixedProviderRateMultipliers: service.CloneMixedProviderRateMultipliers(g.MixedProviderRateMultipliers), + MixedProviderRateMultipliers: g.MixedProviderRateMultipliers, IsExclusive: g.IsExclusive, Status: g.Status, Hydrated: true, diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 26d4187..98be5fd 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "sort" "strings" @@ -99,7 +100,7 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi SetDescription(groupIn.Description). SetPlatform(groupIn.Platform). SetRateMultiplier(groupIn.RateMultiplier). - SetMixedProviderRateMultipliers(service.CloneMixedProviderRateMultipliers(groupIn.MixedProviderRateMultipliers)). + SetMixedProviderRateMultipliers(maps.Clone(groupIn.MixedProviderRateMultipliers)). SetSortOrder(groupIn.SortOrder). SetIsExclusive(groupIn.IsExclusive). SetStatus(groupIn.Status). @@ -294,7 +295,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetDescription(groupIn.Description). SetPlatform(groupIn.Platform). SetRateMultiplier(groupIn.RateMultiplier). - SetMixedProviderRateMultipliers(service.CloneMixedProviderRateMultipliers(groupIn.MixedProviderRateMultipliers)). + SetMixedProviderRateMultipliers(maps.Clone(groupIn.MixedProviderRateMultipliers)). SetIsExclusive(groupIn.IsExclusive). SetStatus(groupIn.Status). SetSubscriptionType(groupIn.SubscriptionType). diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 6ed9556..9f8efea 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -365,8 +365,6 @@ func TestAPIContracts(t *testing.T) { "description": "desc", "platform": "anthropic", "rate_multiplier": 1.5, - "supported_key_providers": [], - "supported_model_vendors": [], "peak_rate_enabled": false, "peak_start": "", "peak_end": "", @@ -877,7 +875,7 @@ func TestAPIContracts(t *testing.T) { "force_email_on_third_party_signup": false, "default_concurrency": 5, "default_balance": 1.25, - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"cline":{"daily":null,"weekly":null,"monthly":null},"command_code":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"mixed":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"cline":{"daily":null,"weekly":null,"monthly":null},"command_code":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, @@ -1205,7 +1203,7 @@ func TestAPIContracts(t *testing.T) { "purchase_subscription_url": "", "table_default_page_size": 20, "table_page_size_options": [10, 20, 50], - "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"cline":{"daily":null,"weekly":null,"monthly":null},"command_code":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"mixed":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, + "default_platform_quotas": {"anthropic":{"daily":null,"weekly":null,"monthly":null},"antigravity":{"daily":null,"weekly":null,"monthly":null},"cline":{"daily":null,"weekly":null,"monthly":null},"command_code":{"daily":null,"weekly":null,"monthly":null},"deepseek":{"daily":null,"weekly":null,"monthly":null},"gemini":{"daily":null,"weekly":null,"monthly":null},"grok":{"daily":null,"weekly":null,"monthly":null},"kimi":{"daily":null,"weekly":null,"monthly":null},"minimax":{"daily":null,"weekly":null,"monthly":null},"openai":{"daily":null,"weekly":null,"monthly":null},"typesafe":{"daily":null,"weekly":null,"monthly":null},"zhipu":{"daily":null,"weekly":null,"monthly":null}}, "auth_source_default_email_platform_quotas": null, "auth_source_default_github_platform_quotas": null, "auth_source_default_google_platform_quotas": null, diff --git a/backend/internal/server/middleware/api_key_auth.go b/backend/internal/server/middleware/api_key_auth.go index 2547cd1..de0a942 100644 --- a/backend/internal/server/middleware/api_key_auth.go +++ b/backend/internal/server/middleware/api_key_auth.go @@ -455,10 +455,16 @@ func validateAPIKeyGroupAllowed(apiKey *service.APIKey) bool { } func validateAPIKeyGroupAvailable(apiKey *service.APIKey) (string, string, bool) { + if apiKey != nil && service.NormalizeModelScope(apiKey.ModelScope) != service.ModelScopeAll { + return "LEGACY_KEY_SCOPE_UNSUPPORTED", "This API key uses a retired provider scope; migrate it to an explicit native group before use", false + } if apiKey == nil || apiKey.GroupID == nil { return "", "", true } group := apiKey.Group + if group != nil && group.Platform == "mixed" { + return "LEGACY_PLATFORM_UNSUPPORTED", "Mixed has been retired; migrate this group to an explicit native platform before use", false + } if group == nil || strings.EqualFold(group.Status, "deleted") { return "GROUP_DELETED", "API Key 所属分组已删除", false } diff --git a/backend/internal/server/middleware/api_key_model_scope_test.go b/backend/internal/server/middleware/api_key_model_scope_test.go deleted file mode 100644 index 9aaa575..0000000 --- a/backend/internal/server/middleware/api_key_model_scope_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package middleware - -import ( - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/service" - "github.com/stretchr/testify/require" -) - -func TestAPIKeyModelScopeIndependentOfGroupAllowlist(t *testing.T) { - for _, scope := range []string{"openai", "anthropic", "cn", "other"} { - for _, model := range []string{"gpt-5", "claude-4", "qwen3", "gemini-3", "grok-4"} { - key := &service.APIKey{ModelScope: scope, Group: &service.Group{Platform: service.PlatformMixed}} - router, calls := newGroupModelAllowlistTestRouter(key, "/v1") - req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"`+model+`"}`)) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Upgrade", "websocket") // spoofed upgrade cannot bypass HTTP admission - response := httptest.NewRecorder() - router.ServeHTTP(response, req) - if service.ClassifyModelScope(model) == scope { - require.Equal(t, http.StatusOK, response.Code) - require.Len(t, *calls, 1) - } else { - require.Equal(t, http.StatusNotFound, response.Code) - require.Empty(t, *calls) - } - } - } -} - -func TestAPIKeyModelScopeDuplicateAndLegacy(t *testing.T) { - key := &service.APIKey{ModelScope: "cn", Group: &service.Group{Platform: service.PlatformMixed}} - router, calls := newGroupModelAllowlistTestRouter(key, "/v1") - response := doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"qwen3","Model":"gpt-5"}`) - require.Equal(t, http.StatusNotFound, response.Code) - require.Empty(t, *calls) - key.ModelScope = "" - response = doJSON(t, router, http.MethodPost, "/v1/messages", `{"model":"gpt-5"}`) - require.Equal(t, http.StatusOK, response.Code) -} diff --git a/backend/internal/server/middleware/group_model_allowlist.go b/backend/internal/server/middleware/group_model_allowlist.go index e6818b7..cd666b3 100644 --- a/backend/internal/server/middleware/group_model_allowlist.go +++ b/backend/internal/server/middleware/group_model_allowlist.go @@ -32,15 +32,7 @@ import ( func GroupModelAllowlist() gin.HandlerFunc { return func(c *gin.Context) { apiKey, ok := GetAPIKeyFromContext(c) - if !ok || apiKey == nil { - c.Next() - return - } - if c.Request != nil { - c.Request = c.Request.WithContext(service.ContextWithModelScope(c.Request.Context(), apiKey.ModelScope)) - } - allowlistEnabled := apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() - if !allowlistEnabled && service.NormalizeModelScope(apiKey.ModelScope) == service.ModelScopeAll { + if !ok || apiKey == nil || apiKey.Group == nil || !apiKey.Group.ModelAllowlistEnabled() { c.Next() return } @@ -79,7 +71,7 @@ func GroupModelAllowlist() gin.HandlerFunc { blocked := "" for _, candidate := range models { - if !apiKey.AllowsModelScope(candidate) || (allowlistEnabled && !apiKey.Group.ModelAllowlist.Allows(candidate)) { + if !apiKey.Group.ModelAllowlist.Allows(candidate) { blocked = candidate break } diff --git a/backend/internal/server/middleware/legacy_platform_test.go b/backend/internal/server/middleware/legacy_platform_test.go new file mode 100644 index 0000000..23c20d4 --- /dev/null +++ b/backend/internal/server/middleware/legacy_platform_test.go @@ -0,0 +1,33 @@ +//go:build unit + +package middleware + +import ( + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestLegacyPlatformAndKeyScopeFailClosed(t *testing.T) { + for _, tc := range []struct { + platform, scope, code string + }{ + {"mixed", "all", "LEGACY_PLATFORM_UNSUPPORTED"}, + {service.PlatformOpenAI, "openai", "LEGACY_KEY_SCOPE_UNSUPPORTED"}, + {service.PlatformAnthropic, "anthropic", "LEGACY_KEY_SCOPE_UNSUPPORTED"}, + {service.PlatformKimi, "cn", "LEGACY_KEY_SCOPE_UNSUPPORTED"}, + {service.PlatformGemini, "other", "LEGACY_KEY_SCOPE_UNSUPPORTED"}, + {service.PlatformKimi, "all", ""}, + {service.PlatformZhipu, "all", ""}, + {service.PlatformDeepseek, "all", ""}, + {service.PlatformMiniMax, "", ""}, + } { + t.Run(tc.platform+"/"+tc.scope, func(t *testing.T) { + group := &service.Group{ID: 1, Platform: tc.platform, Status: service.StatusActive} + code, _, ok := validateAPIKeyGroupAvailable(&service.APIKey{GroupID: &group.ID, Group: group, ModelScope: tc.scope}) + require.Equal(t, tc.code, code) + require.Equal(t, tc.code == "", ok) + }) + } +} diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index f17b0b9..2902309 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -54,8 +54,9 @@ func RegisterGatewayRoutes( isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool { switch getGroupPlatform(c) { - case service.PlatformOpenAI, service.PlatformMixed, service.PlatformGrok, - service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformCommandCode, service.PlatformCline: + case service.PlatformOpenAI, service.PlatformGrok, + service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, + service.PlatformCline, service.PlatformCommandCode: // 国产 OpenAI 兼容供应商与 openai/grok 一样经 OpenAI 网关转发。 return true default: @@ -66,7 +67,7 @@ func RegisterGatewayRoutes( switch getGroupPlatform(c) { case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformCommandCode, service.PlatformCline: h.OpenAIGateway.CountTokens(c) - case service.PlatformGrok, service.PlatformMixed: + case service.PlatformGrok: h.OpenAIGateway.GrokCountTokens(c) default: h.Gateway.CountTokens(c) @@ -83,7 +84,7 @@ func RegisterGatewayRoutes( h.Gateway.Models(c) } isOpenAIOnlyEndpointGatewayPlatform := func(c *gin.Context) bool { - return getGroupPlatform(c) != service.PlatformMixed && isOpenAIResponsesCompatibleGatewayPlatform(c) + return isOpenAIResponsesCompatibleGatewayPlatform(c) } imagesHandler := func(c *gin.Context) { switch getGroupPlatform(c) { diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index bf574b3..750a63f 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -222,6 +222,9 @@ func (a *Account) EffectiveLoadFactor() int { } func (a *Account) IsSchedulable() bool { + if a.Platform == "mixed" { + return false + } if !a.IsActive() || !a.Schedulable { return false } @@ -747,9 +750,6 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri } } if len(result) > 0 { - if a.IsMixed() { - addDomesticModelAliases(result) - } if a.Platform == domain.PlatformAntigravity { ensureAntigravityDefaultPassthroughs(result, antigravityDefaultPassthroughModels) applyAntigravityGemini31ProAliases(result) @@ -1248,9 +1248,6 @@ func (a *Account) IsCustomErrorCodesEnabled() bool { // IsPoolMode 检查 API Key 账号是否启用池模式。 // 池模式下,上游错误不标记本地账号状态,而是在同一账号上重试。 func (a *Account) IsPoolMode() bool { - if a.IsMixed() { - return false - } if !a.IsAPIKeyOrBedrock() || a.Credentials == nil { return false } @@ -1515,15 +1512,9 @@ func (a *Account) IsOpenAIApiKey() bool { // 适用 openai 与国产 OpenAI 兼容供应商(kimi/zhipu/deepseek);grok 走 GetGrokBaseURL, // 此处对 grok 返回 "" 以保持原有行为。 func (a *Account) GetOpenAIBaseURL() string { - if native := mixedOfficialProviderView(a); native != nil { - return native.GetOpenAIBaseURL() - } if a.IsCommandCode() || a.IsCline() { return a.GetCNProtocolBaseURL(APIProtocolChatCompletions) } - if a.IsMixed() { - return normalizeMixedBaseURL(a.GetCredential("base_url")) - } if !a.IsOpenAI() && !a.IsMultiProtocolAPIKey() && !a.IsGeminiOpenAIProtocol() { return "" } @@ -1576,9 +1567,6 @@ func (a *Account) IsCodingPlan() bool { // Gemini 自定义上游。 // MiniMax also supports native Responses; Zhipu does not. func (a *Account) GetAPIProtocol() string { - if native := mixedOfficialProviderView(a); native != nil { - return native.GetAPIProtocol() - } if a == nil || (!a.IsMultiProtocolAPIKey() && !a.IsGeminiOpenAIProtocol()) { return APIProtocolChatCompletions } @@ -1649,9 +1637,6 @@ func (a *Account) IsAdaptiveAPIProtocol() bool { // adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和 // account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。 func (a *Account) GetCNProtocolBaseURL(protocol string) string { - if native := mixedOfficialProviderView(a); native != nil { - return mixedOfficialDefaultURL(a, native.GetCNProtocolBaseURL(protocol)) - } if a == nil || (!a.IsMultiProtocolAPIKey() && !a.IsGeminiOpenAIProtocol()) { return "" } @@ -1716,9 +1701,6 @@ func (a *Account) IsAnthropicProtocol() bool { // (上游路径为 {base}/v1/messages)。优先取凭证 base_url,缺失时按 // 供应商 × 接入模式返回默认端点。非 Anthropic 协议账号返回空串。 func (a *Account) GetAnthropicProtocolBaseURL() string { - if native := mixedOfficialProviderView(a); native != nil { - return native.GetAnthropicProtocolBaseURL() - } if a == nil || (!a.IsAnthropicProtocol() && !a.IsAdaptiveAPIProtocol()) { return "" } @@ -1739,9 +1721,6 @@ func (a *Account) GetAnthropicProtocolBaseURL() string { // 端点,不能拿来拼 OpenAI 路径,此时返回该供应商 × 模式的 Chat Completions // 默认 base(模型同步等协议族共用路径仍可用)。 func (a *Account) GetOpenAIFormatBaseURL() string { - if native := mixedOfficialProviderView(a); native != nil { - return mixedOfficialDefaultURL(a, native.GetOpenAIFormatBaseURL()) - } if a.IsCommandCode() || a.IsCline() { return a.GetCNProtocolBaseURL(APIProtocolChatCompletions) } @@ -1945,7 +1924,7 @@ func (a *Account) GetOpenAIProtocolAPIKey() string { if a.IsCloudflareOpenAI() { return a.GetCredential("api_key") } - if a.IsMixed() || a.IsMultiProtocolAPIKey() || a.IsGeminiOpenAIProtocol() { + if a.IsMultiProtocolAPIKey() || a.IsGeminiOpenAIProtocol() { if a.Type != AccountTypeAPIKey { return "" } @@ -2014,9 +1993,6 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa if a == nil { return false } - if a.IsMixed() { - return a.Type == AccountTypeAPIKey && (capability == "" || capability == OpenAIEndpointCapabilityChatCompletions || capability == OpenAIEndpointCapabilityResponses) - } if capability == "" { return true } diff --git a/backend/internal/service/account_domestic_model_aliases.go b/backend/internal/service/account_domestic_model_aliases.go deleted file mode 100644 index 945e217..0000000 --- a/backend/internal/service/account_domestic_model_aliases.go +++ /dev/null @@ -1,49 +0,0 @@ -package service - -import "strings" - -// addDomesticModelAliases augments a newly resolved catalog, never credentials or -// a published memo. Only recognized provider/family pairs are eligible. Targets -// remain byte-for-byte identical so routing and upstream billing are unchanged. -func addDomesticModelAliases(mapping map[string]string) { - aliases := make(map[string]string) - ambiguous := make(map[string]bool) - for model, target := range mapping { - provider, family, ok := strings.Cut(model, "/") - if !ok || strings.ContainsAny(family, "/*") { - continue - } - prefix := "" - switch strings.ToLower(provider) { - case "deepseek-ai", "deepseek": - prefix = "deepseek-" - case "moonshotai": - prefix = "kimi-" - case "z-ai", "zhipuai": - prefix = "glm-" - case "minimaxai": - prefix = "minimax-" - case "qwen": - prefix = "qwen" - case "xiaomi", "xiaomimimo": - prefix = "mimo-" - case "stepfun-ai": - prefix = "step-" - } - if prefix == "" || !strings.HasPrefix(strings.ToLower(family), prefix) || len(family) <= len(prefix) { - continue - } - if _, explicit := mapping[family]; explicit { - continue - } - if _, exists := aliases[family]; exists { - ambiguous[family] = true - } - aliases[family] = target - } - for alias, target := range aliases { - if !ambiguous[alias] { - mapping[alias] = target - } - } -} diff --git a/backend/internal/service/account_domestic_model_aliases_test.go b/backend/internal/service/account_domestic_model_aliases_test.go deleted file mode 100644 index 25a907b..0000000 --- a/backend/internal/service/account_domestic_model_aliases_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package service - -import ( - "sync" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestDomesticModelAliasesPreserveCatalogAndTargets(t *testing.T) { - raw := map[string]any{ - "deepseek-ai/deepseek-v4-pro-0813": "upstream/actual-model", - "moonshotai/kimi-k2.6": "moonshotai/kimi-k2.6", - "z-ai/glm-5.3": "z-ai/glm-5.3", - "MiniMaxAI/MiniMax-M3": "MiniMaxAI/MiniMax-M3", - "Qwen/Qwen3.8-27B-FP8": "Qwen/Qwen3.8-27B-FP8", - "xiaomi/mimo-v2-flash": "xiaomi/mimo-v2-flash", - "stepfun-ai/step-3.5-flash": "stepfun-ai/step-3.5-flash", - "custom-alias": "deepseek-ai/deepseek-hidden", - "unknown/glm-5": "unknown/glm-5", - "deepseek-ai/gpt-5": "deepseek-ai/gpt-5", - "z-ai/glm-*": "wildcard", - } - a := &Account{Platform: PlatformMixed, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": raw}} - mapping := a.GetModelMapping() - for key, target := range raw { - require.Equal(t, target, mapping[key]) - } - for alias, target := range map[string]string{ - "deepseek-v4-pro-0813": "upstream/actual-model", "kimi-k2.6": "moonshotai/kimi-k2.6", - "glm-5.3": "z-ai/glm-5.3", "MiniMax-M3": "MiniMaxAI/MiniMax-M3", - "Qwen3.8-27B-FP8": "Qwen/Qwen3.8-27B-FP8", "mimo-v2-flash": "xiaomi/mimo-v2-flash", - "step-3.5-flash": "stepfun-ai/step-3.5-flash", - } { - require.Equal(t, target, a.GetMappedModel(alias)) - require.True(t, a.IsModelSupported(alias)) - require.NotContains(t, raw, alias, "derived aliases must not mutate persisted credentials") - } - for _, excluded := range []string{"deepseek-hidden", "glm-5", "gpt-5", "glm-*", "qwen3.8-27b-fp8", "deepseek-v4-pro"} { - require.NotContains(t, mapping, excluded) - } - // Readers only see complete, immutable maps, including concurrent cold misses. - var readers sync.WaitGroup - for range 16 { - readers.Go(func() { _ = a.GetModelMapping()["kimi-k2.6"] }) - } - readers.Wait() - raw["moonshotai/kimi-k2.6"] = "new-target" - require.Equal(t, "new-target", a.GetMappedModel("kimi-k2.6")) - require.Equal(t, "moonshotai/kimi-k2.6", mapping["kimi-k2.6"], "previous memo remains immutable") -} - -func TestDomesticModelAliasesExplicitAndAmbiguous(t *testing.T) { - for _, explicit := range []bool{false, true} { - raw := map[string]any{"z-ai/glm-5": "first", "zhipuai/glm-5": "second"} - if explicit { - raw["glm-5"] = "explicit" - } - a := &Account{Platform: PlatformMixed, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": raw}} - if explicit { - require.Equal(t, "explicit", a.GetMappedModel("glm-5")) - } else { - require.NotContains(t, a.GetModelMapping(), "glm-5") - } - } - for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { - a := &Account{Platform: platform, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"moonshotai/kimi-k2.6": "raw"}}} - require.NotContains(t, a.GetModelMapping(), "kimi-k2.6", platform) - } -} diff --git a/backend/internal/service/account_header_override.go b/backend/internal/service/account_header_override.go index 95893bd..3668097 100644 --- a/backend/internal/service/account_header_override.go +++ b/backend/internal/service/account_header_override.go @@ -78,7 +78,7 @@ func (a *Account) IsHeaderOverrideEligible() bool { return false } switch a.Platform { - case PlatformAnthropic, PlatformOpenAI, PlatformMixed, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformCommandCode, PlatformCline: + case PlatformAnthropic, PlatformOpenAI, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformCline, PlatformCommandCode: return a.Type == AccountTypeAPIKey || (a.Platform == PlatformOpenAI && a.Type == AccountTypeCloudflare) case PlatformGrok: return a.Type == AccountTypeAPIKey || a.Type == AccountTypeOAuth diff --git a/backend/internal/service/account_mixed.go b/backend/internal/service/account_mixed.go deleted file mode 100644 index 62b6335..0000000 --- a/backend/internal/service/account_mixed.go +++ /dev/null @@ -1,72 +0,0 @@ -package service - -import ( - "context" - "fmt" - "net/url" - "strings" - - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" -) - -// IsMixed identifies the isolated API-key platform. It must never imply -// IsOpenAI, IsCNProvider, OAuth, passthrough, or provider-specific discovery. -func (a *Account) IsMixed() bool { return a != nil && a.Platform == PlatformMixed } - -// Strip only known wire endpoints. Preserve an explicit vendor prefix/version -// so every protocol attempt starts from the same configured origin and base. -func normalizeMixedBaseURL(base string) string { - u, err := url.Parse(strings.TrimSpace(base)) - if err != nil { - return strings.TrimSpace(base) - } - path := strings.TrimRight(u.Path, "/") - for _, endpoint := range []string{"/chat/completions", "/responses", "/messages"} { - if strings.HasSuffix(path, endpoint) { - u.Path = strings.TrimSuffix(path, endpoint) - u.RawPath = "" - break - } - } - return u.String() -} - -func validateMixedAccount(account *Account) error { - if !account.IsMixed() { - return nil - } - if account.Type != AccountTypeAPIKey { - return infraerrors.BadRequest("INVALID_MIXED_ACCOUNT", "Mixed supports API-key accounts only") - } - if strings.TrimSpace(account.GetCredential("api_key")) == "" { - return infraerrors.BadRequest("INVALID_MIXED_ACCOUNT", "Mixed requires an explicit api_key") - } - base, err := url.Parse(strings.TrimSpace(account.GetCredential("base_url"))) - if err != nil || base.Hostname() == "" || (base.Scheme != "https" && base.Scheme != "http") || base.User != nil || base.RawQuery != "" || base.Fragment != "" { - return infraerrors.BadRequest("INVALID_MIXED_ACCOUNT", "Mixed requires an explicit HTTP(S) base_url without user info, query, or fragment") - } - return nil -} - -// Validate only the new boundary: old platform-to-platform binding rules are -// unchanged. Composite groups may contain Mixed accounts without acquiring -// the Mixed group's implicit Messages/conversion permissions. -func validateMixedGroupBindings(ctx context.Context, repo GroupRepository, account *Account, groupIDs []int64) error { - for _, id := range groupIDs { - if repo == nil { - return fmt.Errorf("group repository not configured") - } - group, err := repo.GetByID(ctx, id) - if err != nil { - return err - } - if group == nil { - return ErrGroupNotFound - } - if (group.Platform == PlatformMixed && (!account.IsMixed() || account.Type != AccountTypeAPIKey)) || - (account.IsMixed() && group.Platform != PlatformMixed && group.Platform != PlatformComposite) { - return infraerrors.BadRequest("MIXED_PLATFORM_BINDING_MISMATCH", "Mixed accounts require a Mixed or Composite group; Mixed groups accept only Mixed API-key accounts") - } - } - return nil -} diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 3df5ffa..d570a10 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -253,9 +253,6 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) ( Status: StatusActive, ExpiresAt: req.ExpiresAt, } - if err := validateMixedAccount(account); err != nil { - return nil, err - } if err := validateCloudflareAccount(account); err != nil { return nil, err } @@ -265,9 +262,6 @@ func (s *AccountService) Create(ctx context.Context, req CreateAccountRequest) ( account.AutoPauseOnExpired = true } - if err := validateMixedGroupBindings(ctx, s.groupRepo, account, req.GroupIDs); err != nil { - return nil, err - } if err := s.accountRepo.Create(ctx, account); err != nil { return nil, fmt.Errorf("create account: %w", err) } @@ -352,9 +346,6 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount if req.Credentials != nil { account.Credentials = SanitizeStoredCredentials(account.Platform, *req.Credentials) } - if err := validateMixedAccount(account); err != nil { - return nil, err - } if err := validateCloudflareAccount(account); err != nil { return nil, err } @@ -402,11 +393,6 @@ func (s *AccountService) Update(ctx context.Context, id int64, req UpdateAccount } // 执行更新 - if req.GroupIDs != nil { - if err := validateMixedGroupBindings(ctx, s.groupRepo, account, *req.GroupIDs); err != nil { - return nil, err - } - } if err := s.accountRepo.Update(ctx, account); err != nil { return nil, fmt.Errorf("update account: %w", err) } diff --git a/backend/internal/service/account_test_mixed.go b/backend/internal/service/account_test_mixed.go deleted file mode 100644 index 4574369..0000000 --- a/backend/internal/service/account_test_mixed.go +++ /dev/null @@ -1,42 +0,0 @@ -package service - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - - "github.com/gin-gonic/gin" -) - -// Connectivity tests use the live dispatcher and its bounded refusal policy. -// They test exactly the selected model and never persist capability guesses. -func (s *AccountTestService) testMixedAccountConnection(c *gin.Context, account *Account, model, prompt string) error { - if err := validateMixedAccount(account); err != nil { - return s.sendErrorAndEnd(c, err.Error()) - } - model = strings.TrimSpace(model) - if model == "" { - return s.sendErrorAndEnd(c, "Select a model to test") - } - if s.openaiGatewayService == nil { - return s.sendErrorAndEnd(c, "Mixed gateway is unavailable") - } - body, err := json.Marshal(createOpenAIChatCompletionsTestPayload(model, prompt)) - if err != nil { - return s.sendErrorAndEnd(c, "Failed to build test request") - } - recorder := httptest.NewRecorder() - forwardContext, _ := gin.CreateTestContext(recorder) - forwardContext.Request, err = http.NewRequestWithContext(c.Request.Context(), http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)) - if err != nil { - return s.sendErrorAndEnd(c, "Failed to build test request") - } - s.sendEvent(c, TestEvent{Type: "test_start", Model: model}) - result, err := s.openaiGatewayService.ForwardAsChatCompletions(c.Request.Context(), forwardContext, account, body, "", "") - if err != nil || result == nil || recorder.Code != http.StatusOK { - return s.sendErrorAndEnd(c, "Mixed model test failed at "+GetActualOpenAIUpstreamEndpoint(forwardContext)) - } - return s.processOpenAIChatCompletionsStreamWithTextRequirement(c, recorder.Body, true) -} diff --git a/backend/internal/service/account_test_mixed_test.go b/backend/internal/service/account_test_mixed_test.go deleted file mode 100644 index 0f6d6cc..0000000 --- a/backend/internal/service/account_test_mixed_test.go +++ /dev/null @@ -1,175 +0,0 @@ -//go:build unit - -package service - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func mixedChatTextSSE(text string) string { - chunk, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": map[string]string{"content": text}, "finish_reason": "stop"}}}) - return "data: " + string(chunk) + "\n\ndata: [DONE]\n\n" -} - -func mixedResponsesTextSSE() string { - return strings.Join([]string{ - `data: {"type":"response.created","response":{"id":"resp_text","model":"actual","status":"in_progress","output":[]}}`, "", - `data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"Hello"}`, "", - `data: {"type":"response.completed","response":{"id":"resp_text","object":"response","model":"actual","status":"completed","output":[{"type":"message","id":"msg_text","role":"assistant","status":"completed","content":[{"type":"output_text","text":"Hello"}]}],"usage":{"input_tokens":9,"output_tokens":4}}}`, "", "", - }, "\n") -} - -func runMixedAccountProbe(t *testing.T, upstream *httpUpstreamRecorder, account *Account, model, prompt string) (string, error) { - t.Helper() - gateway := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - svc := &AccountTestService{openaiGatewayService: gateway} - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPost, "/test", nil) - err := svc.testMixedAccountConnection(c, account, model, prompt) - return rec.Body.String(), err -} - -func TestMixedConnectivityRequiresReplyText(t *testing.T) { - for _, tt := range []struct { - name, body string - wantText string - }{ - {"normal", mixedChatTextSSE("Hello"), "Hello"}, - {"finish without done", strings.ReplaceAll(mixedChatTextSSE("Hello"), "data: [DONE]\n\n", ""), "Hello"}, - {"truncated text", "data: " + `{"choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}` + "\n\n", ""}, - {"empty", mixedChatTextSSE(""), ""}, - {"null content", "data: " + `{"choices":[{"delta":{"content":null},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n", ""}, - {"whitespace", mixedChatTextSSE(" \t\n"), ""}, - {"reasoning only", strings.ReplaceAll(mixedChatTextSSE("thinking"), `"content"`, `"reasoning_content"`), ""}, - {"tool only", mixedChatToolSSE(), ""}, - {"no body", "", ""}, - {"done only", "data: [DONE]\n\n", ""}, - {"array content", "data: " + `{"choices":[{"delta":{"content":[{"type":"text","text":"Hello"}]},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n", ""}, - {"number content", "data: " + `{"choices":[{"delta":{"content":42},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n", ""}, - {"invalid choices", "data: " + `{"choices":{"delta":{"content":"Hello"}}}` + "\n\ndata: [DONE]\n\n", ""}, - {"invalid message", "data: " + `{"choices":[{"delta":["Hello"],"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n", ""}, - } { - t.Run(tt.name, func(t *testing.T) { - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(http.StatusOK, tt.body, true)} - body, err := runMixedAccountProbe(t, upstream, mixedTestAccount(), "alias", "Hi") - require.Len(t, upstream.requests, 1) - require.NotContains(t, body, "Upstream:") - text, probeErr := parseTestSSEOutput(body) - if tt.wantText != "" { - require.NoError(t, err) - require.Empty(t, probeErr) - require.Equal(t, tt.wantText, text) - require.Contains(t, body, `"success":true`) - require.NotContains(t, body, "/v1/") - } else { - require.Error(t, err) - require.NotEmpty(t, probeErr) - require.NotContains(t, body, `"success":true`) - } - }) - } -} - -func TestMixedConnectivityReusesPayloadAndMapsModelOnce(t *testing.T) { - for _, prompt := range []string{" Reply normally ", " \t "} { - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(http.StatusOK, mixedChatTextSSE("Hello"), true)} - account := mixedTestAccount() - before, err := json.Marshal(account) - require.NoError(t, err) - body, err := runMixedAccountProbe(t, upstream, account, " alias ", prompt) - require.NoError(t, err) - require.Contains(t, body, `"model":"alias"`) - var payload map[string]any - require.NoError(t, json.Unmarshal(upstream.lastBody, &payload)) - require.Equal(t, "actual", payload["model"]) - require.Equal(t, true, payload["stream"]) - require.NotContains(t, payload, "max_tokens") - require.NotContains(t, payload, "max_completion_tokens") - require.NotContains(t, payload, "enable_thinking") - require.NotContains(t, payload, "thinking") - require.NotContains(t, payload, "reasoning_effort") - payload["model"] = "alias" - delete(payload, "stream_options") // The shared raw dispatcher adds usage reporting. - expected, err := json.Marshal(createOpenAIChatCompletionsTestPayload("alias", prompt)) - require.NoError(t, err) - actual, err := json.Marshal(payload) - require.NoError(t, err) - require.JSONEq(t, string(expected), string(actual)) - require.Equal(t, "upstream.example", upstream.lastReq.URL.Host) - require.Equal(t, "Bearer test-secret", upstream.lastReq.Header.Get("Authorization")) - after, err := json.Marshal(account) - require.NoError(t, err) - require.JSONEq(t, string(before), string(after)) - } -} - -func TestMixedConnectivityFallbackRequiresReplyText(t *testing.T) { - for _, tt := range []struct { - name, target, reply string - wantSuccess bool - }{ - {"responses text", APIProtocolResponses, mixedResponsesTextSSE(), true}, - {"anthropic text", APIProtocolAnthropic, strings.ReplaceAll(miniAnthropicSSEStream(), "glm-4.7", "actual"), true}, - {"responses tools", APIProtocolResponses, mixedResponsesToolSSE(), false}, - {"anthropic tools", APIProtocolAnthropic, toolAnthropicSSEStream(), false}, - } { - t.Run(tt.name, func(t *testing.T) { - responses := []*http.Response{mixedRefusal(APIProtocolChatCompletions)} - if tt.target == APIProtocolAnthropic { - responses = append(responses, mixedRefusal(APIProtocolResponses)) - } - responses = append(responses, mixedHTTPResponse(http.StatusOK, tt.reply, true)) - attempts := len(responses) - upstream := &httpUpstreamRecorder{responses: responses} - body, err := runMixedAccountProbe(t, upstream, mixedTestAccount(), "alias", "Hi") - require.Len(t, upstream.requests, attempts) - require.Equal(t, protocolUpstreamEndpoint(tt.target), upstream.lastReq.URL.Path) - text, probeErr := parseTestSSEOutput(body) - if tt.wantSuccess { - require.NoError(t, err) - require.Empty(t, probeErr) - require.Equal(t, "Hello", text) - require.Contains(t, body, `"success":true`) - require.NotContains(t, body, "/v1/") - } else { - require.Error(t, err) - require.Empty(t, text) - require.NotEmpty(t, probeErr) - require.NotContains(t, body, `"success":true`) - } - for _, request := range upstream.requests { - require.Equal(t, "upstream.example", request.URL.Host) - } - }) - } -} - -func TestMixedTextRequirementKeepsLegacyParserBehavior(t *testing.T) { - svc := &AccountTestService{} - for _, requireText := range []bool{false, true} { - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - var err error - if requireText { - err = svc.processOpenAIChatCompletionsStreamWithTextRequirement(c, strings.NewReader(mixedChatToolSSE()), true) - } else { - err = svc.processOpenAIChatCompletionsStream(c, strings.NewReader(mixedChatToolSSE())) - } - if requireText { - require.Error(t, err) - require.NotContains(t, rec.Body.String(), `"success":true`) - } else { - require.NoError(t, err) - require.Contains(t, rec.Body.String(), `"success":true`) - require.Contains(t, rec.Body.String(), "/v1/chat/completions") - } - } -} diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index 1320b3a..ee23a79 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -352,6 +352,10 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int return s.sendErrorAndEnd(c, "Account not found") } + if account.Platform == "mixed" { + return s.sendErrorAndEnd(c, "Mixed has been retired; migrate this account to an explicit native platform before testing") + } + // Synthetic UI load-test accounts exercise the real SSE parsing and modal // interactions, but intentionally do not send their placeholder credentials // to an upstream provider. @@ -373,12 +377,6 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int } return s.testTypeSafeAccountConnection(c, account, prompt) } - if account.IsMixed() { - if mode != "" && mode != "default" && mode != "text" { - return s.sendErrorAndEnd(c, "Mixed supports text tests only") - } - return s.testMixedAccountConnection(c, account, modelID, prompt) - } if account.RoutesProtocolByInbound() { switch account.GetAPIProtocol() { case APIProtocolAdaptive: @@ -2902,33 +2900,18 @@ func (s *AccountTestService) processClaudeStream(c *gin.Context, body io.Reader) // processOpenAIChatCompletionsStream processes SSE chunks from the // OpenAI-compatible Chat Completions API. func (s *AccountTestService) processOpenAIChatCompletionsStream(c *gin.Context, body io.Reader) error { - return s.processOpenAIChatCompletionsStreamWithTextRequirement(c, body, false) -} - -// Mixed text probes require visible reply text; ordinary tests keep their -// existing completion semantics and endpoint status events. -func (s *AccountTestService) processOpenAIChatCompletionsStreamWithTextRequirement(c *gin.Context, body io.Reader, requireText bool) error { reader := bufio.NewReader(body) seenJSON := false seenFinish := false - seenText := false - complete := func() error { - if requireText && !seenText { - return s.sendErrorAndEnd(c, "Mixed model test returned no text content") - } - if !requireText { - s.sendEvent(c, TestEvent{Type: "status", Text: "已通过 /v1/chat/completions 验证"}) - } - s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) - return nil - } for { line, err := reader.ReadString('\n') if err != nil { if err == io.EOF { if seenFinish { - return complete() + s.sendEvent(c, TestEvent{Type: "status", Text: "已通过 /v1/chat/completions 验证"}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil } if seenJSON { return s.sendErrorAndEnd(c, "Chat Completions stream from /v1/chat/completions ended before [DONE]") @@ -2945,7 +2928,9 @@ func (s *AccountTestService) processOpenAIChatCompletionsStreamWithTextRequireme jsonStr := sseDataPrefix.ReplaceAllString(line, "") if jsonStr == "[DONE]" { - return complete() + s.sendEvent(c, TestEvent{Type: "status", Text: "已通过 /v1/chat/completions 验证"}) + s.sendEvent(c, TestEvent{Type: "test_complete", Success: true}) + return nil } var data map[string]any @@ -2964,33 +2949,20 @@ func (s *AccountTestService) processOpenAIChatCompletionsStreamWithTextRequireme choices, ok := data["choices"].([]any) if !ok { - if requireText && data["choices"] != nil { - return s.sendErrorAndEnd(c, "Invalid Chat Completions response: expected choices array") - } continue } for _, choiceValue := range choices { choice, ok := choiceValue.(map[string]any) if !ok { - if requireText { - return s.sendErrorAndEnd(c, "Invalid Chat Completions response: expected choice object") - } continue } - for _, field := range []string{"delta", "message"} { - message, ok := choice[field].(map[string]any) - if !ok { - if requireText && choice[field] != nil { - return s.sendErrorAndEnd(c, "Invalid Chat Completions response: expected message object") - } - continue - } - text, ok := message["content"].(string) - if requireText && !ok && message["content"] != nil { - return s.sendErrorAndEnd(c, "Invalid Chat Completions response: expected text content") + if delta, ok := choice["delta"].(map[string]any); ok { + if text, ok := delta["content"].(string); ok && text != "" { + s.sendEvent(c, TestEvent{Type: "content", Text: text}) } - if ok && text != "" { - seenText = seenText || strings.TrimSpace(text) != "" + } + if message, ok := choice["message"].(map[string]any); ok { + if text, ok := message["content"].(string); ok && text != "" { s.sendEvent(c, TestEvent{Type: "content", Text: text}) } } diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index 190aa04..9b073df 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -419,9 +419,6 @@ func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]an if input.Platform == PlatformTypeSafe && input.Type != AccountTypeAPIKey { return nil, errors.New("typesafe accounts only support apikey credentials") } - if err := validateMixedAccount(&Account{Platform: input.Platform, Type: input.Type, Credentials: input.Credentials}); err != nil { - return nil, err - } if err := ValidateAccountSchedulingFields(&input.Concurrency, &input.Priority); err != nil { return nil, err } @@ -553,9 +550,6 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou if err := s.ValidateAccountGroupBindings(ctx, groupIDs); err != nil { return nil, err } - if err := validateMixedGroupBindings(ctx, s.groupRepo, account, groupIDs); err != nil { - return nil, err - } if err := s.accountRepo.Create(ctx, account); err != nil { return nil, err } @@ -877,14 +871,6 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U } billingSettingsAppliedAtomically := false - if err := validateMixedAccount(account); err != nil { - return nil, err - } - if input.GroupIDs != nil { - if err := validateMixedGroupBindings(ctx, s.groupRepo, account, *input.GroupIDs); err != nil { - return nil, err - } - } updater := s.accountBillingRepo if updater == nil { // Unit tests and narrow internal callers may construct adminServiceImpl @@ -1031,7 +1017,7 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp // 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。 _, _, scoped := AccountOwnerScopeDetail(ctx) var cachedTargets []*Account - if scoped || len(input.Credentials) > 0 || input.GroupIDs != nil || input.ProxyID != nil || needMixedChannelCheck || openAISettings.any() || input.ProbeEnabled != nil || input.RateMultiplier != nil { + if scoped || len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || openAISettings.any() || input.ProbeEnabled != nil || input.RateMultiplier != nil { loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs) if err != nil { return nil, err @@ -1151,31 +1137,6 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp input.Credentials = SanitizeStoredCredentials("", input.Credentials) } - // Validate Mixed constraints against the merged credentials before any write. - for _, account := range cachedTargets { - if account == nil { - continue - } - if account.IsMixed() && len(input.Credentials) > 0 { - candidate := *account - candidate.Credentials = make(map[string]any, len(account.Credentials)+len(input.Credentials)) - for key, value := range account.Credentials { - candidate.Credentials[key] = value - } - for key, value := range input.Credentials { - candidate.Credentials[key] = value - } - if err := validateMixedAccount(&candidate); err != nil { - return nil, err - } - } - if input.GroupIDs != nil { - if err := validateMixedGroupBindings(ctx, s.groupRepo, account, *input.GroupIDs); err != nil { - return nil, err - } - } - } - // Prepare bulk updates for columns and JSONB fields. repoUpdates := AccountBulkUpdate{ Credentials: input.Credentials, diff --git a/backend/internal/service/admin_account_upstream_billing_probe_test.go b/backend/internal/service/admin_account_upstream_billing_probe_test.go index b22aa71..f76dde0 100644 --- a/backend/internal/service/admin_account_upstream_billing_probe_test.go +++ b/backend/internal/service/admin_account_upstream_billing_probe_test.go @@ -189,45 +189,6 @@ func TestUpdateAccountPreservesManagedUpstreamBillingProbeStateForUnrelatedEdit( require.Equal(t, "value", updated.Extra["custom"]) } -func TestUpdateAccountMixedModelMappingPreservesEnabledProbeAndCredentials(t *testing.T) { - const accountID = int64(1143) - rate := 1.0 - account := &Account{ - ID: accountID, Platform: PlatformMixed, Type: AccountTypeAPIKey, Status: StatusActive, - RateMultiplier: &rate, - Credentials: map[string]any{ - "api_key": "nvapi-test", "base_url": "https://nvidia.example/v1", - "model_mapping": map[string]any{"alias": "old-model"}, "preserved_credential": "keep", - }, - Extra: map[string]any{ - UpstreamBillingProbeEnabledExtraKey: true, UpstreamBillingRateSyncEnabledExtraKey: false, - UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"}, "preserved_setting": true, - }, - } - repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{accountID: account}} - enabled := true - // The editor submits the existing non-sensitive credentials with only the - // mapping changed; the redacted API key is retained by the service. - credentials := mergeMap(nil, account.Credentials) - credentials["model_mapping"] = map[string]any{"alias": "new-model"} - delete(credentials, "api_key") - updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ - Credentials: credentials, - ProbeEnabled: &enabled, - }) - require.NoError(t, err) - require.Equal(t, PlatformMixed, updated.Platform) - require.Equal(t, AccountTypeAPIKey, updated.Type) - require.Equal(t, rate, *updated.RateMultiplier) - require.Equal(t, map[string]any{"alias": "new-model"}, updated.Credentials["model_mapping"]) - require.Equal(t, "nvapi-test", updated.Credentials["api_key"]) - require.Equal(t, "https://nvidia.example/v1", updated.Credentials["base_url"]) - require.Equal(t, "keep", updated.Credentials["preserved_credential"]) - require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey]) - require.Equal(t, false, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey]) - require.Equal(t, true, updated.Extra["preserved_setting"]) -} - func TestUpdateAccountPreservesGrokBillingSnapshotForUnrelatedEdit(t *testing.T) { accountID := int64(112) billing := &xai.BillingSummary{ diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go index 24d3535..4e94d64 100644 --- a/backend/internal/service/admin_group.go +++ b/backend/internal/service/admin_group.go @@ -291,8 +291,6 @@ func defaultModelsListCandidateIDs(platform string) []string { switch platform { case PlatformTypeSafe: return []string{"jev-latest"} - case PlatformMixed: - return nil case PlatformOpenAI: return openai.DefaultModelIDs() case PlatformGemini: @@ -401,9 +399,6 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { normalizeCreateGroupInputForSimpleMode(input) } - if NormalizeGroupPlatform(input.Platform) == PlatformMixed { - input.RateMultiplier = 1 // Mixed billing uses only absolute vendor rates. - } if input.RateMultiplier <= 0 { return nil, errors.New("rate_multiplier must be > 0") } @@ -413,9 +408,6 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn if err != nil { return nil, err } - if err := ValidateMixedProviderRateMultipliers(input.MixedProviderRateMultipliers); err != nil { - return nil, err - } // 固定账号 manifest 配置:账号绑定发生在创建之后,创建时无法校验成员关系, // 拒绝开启并在创建后的编辑里配置。 if normalizeCodexModelsManifestConfig(platform, input.CodexModelsManifestConfig).Enabled { @@ -588,7 +580,6 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn I18n: i18n, Platform: platform, RateMultiplier: input.RateMultiplier, - MixedProviderRateMultipliers: CloneMixedProviderRateMultipliers(input.MixedProviderRateMultipliers), IsExclusive: input.IsExclusive, Status: StatusActive, SubscriptionType: subscriptionType, @@ -648,12 +639,6 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit, ReasoningEffortMappings: reasoningEffortMappings, } - if group.Platform == PlatformMixed { - group.RequireOAuthOnly = false - group.RequirePrivacySet = false - group.AllowImageGeneration = false - group.AllowBatchImageGeneration = false - } sanitizeGroupMessagesDispatchFields(group) sanitizeGroupOpenAIFast(group) if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { @@ -801,20 +786,6 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd // 渠道缓存里存了 groupID → platform 的映射,改了平台要让它失效(见函数末尾) previousPlatform := group.Platform - if input.Platform != "" && input.Platform != previousPlatform && (input.Platform == PlatformMixed || previousPlatform == PlatformMixed) { - if s.accountRepo == nil { - return nil, errors.New("account repository not configured") - } - accounts, err := s.accountRepo.ListByGroup(ctx, id) - if err != nil { - return nil, err - } - for _, account := range accounts { - if (input.Platform == PlatformMixed && (!account.IsMixed() || account.Type != AccountTypeAPIKey)) || (account.IsMixed() && input.Platform != PlatformMixed && input.Platform != PlatformComposite) { - return nil, infraerrors.BadRequest("MIXED_PLATFORM_BINDING_MISMATCH", "Move incompatible accounts before changing a group to or from Mixed") - } - } - } if input.Name != "" { group.Name = input.Name @@ -832,18 +803,12 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd if input.Platform != "" { group.Platform = input.Platform } - if input.RateMultiplier != nil && group.Platform != PlatformMixed { + if input.RateMultiplier != nil { if *input.RateMultiplier <= 0 { return nil, errors.New("rate_multiplier must be > 0") } group.RateMultiplier = *input.RateMultiplier } - if input.MixedProviderRateMultipliers != nil { - if err := ValidateMixedProviderRateMultipliers(input.MixedProviderRateMultipliers); err != nil { - return nil, err - } - group.MixedProviderRateMultipliers = CloneMixedProviderRateMultipliers(input.MixedProviderRateMultipliers) - } if input.IsExclusive != nil { group.IsExclusive = *input.IsExclusive } @@ -1102,12 +1067,6 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd } group.ReasoningEffortMappings = reasoningEffortMappings } - if group.Platform == PlatformMixed { - group.RequireOAuthOnly = false - group.RequirePrivacySet = false - group.AllowImageGeneration = false - group.AllowBatchImageGeneration = false - } sanitizeGroupMessagesDispatchFields(group) sanitizeGroupOpenAIFast(group) if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { @@ -1335,17 +1294,10 @@ func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, gro if err := s.ValidateSimpleModeGroupOperation(AdminGroupOperationMultiplier); err != nil { return err } - zeroValidated := false for _, e := range entries { if err := validateUserGroupRateMultiplier(e.RateMultiplier); err != nil { return fmt.Errorf("%w (user_id=%d)", err, e.UserID) } - if e.RateMultiplier == 0 && !zeroValidated { - if err := s.validateZeroUserGroupRate(ctx, groupID); err != nil { - return err - } - zeroValidated = true - } } if s.userGroupRateRepo == nil { return nil diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index a5b0d38..da169e0 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -100,7 +100,6 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { I18n: cloneGroupI18nForDuplicate(source.I18n), Platform: source.Platform, RateMultiplier: source.RateMultiplier, - MixedProviderRateMultipliers: CloneMixedProviderRateMultipliers(source.MixedProviderRateMultipliers), PeakRateEnabled: source.PeakRateEnabled, PeakStart: source.PeakStart, PeakEnd: source.PeakEnd, diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index ad99f6b..f5acb48 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -233,19 +233,18 @@ type AdminBoundAuthIdentityChannel struct { } type CreateGroupInput struct { - Name string - Description string - I18n GroupI18n - Platform string - RateMultiplier float64 - MixedProviderRateMultipliers map[string]float64 - IsExclusive bool - SubscriptionType string // standard/subscription - DailyLimitUSD *float64 // 日限额 (USD) - WeeklyLimitUSD *float64 // 周限额 (USD) - MonthlyLimitUSD *float64 // 月限额 (USD) - LongContextPricingEnabled bool - ModelPricing []ChannelModelPricing + I18n GroupI18n + Name string + Description string + Platform string + RateMultiplier float64 + IsExclusive bool + SubscriptionType string // standard/subscription + DailyLimitUSD *float64 // 日限额 (USD) + WeeklyLimitUSD *float64 // 周限额 (USD) + MonthlyLimitUSD *float64 // 月限额 (USD) + LongContextPricingEnabled bool + ModelPricing []ChannelModelPricing // 图片生成计费配置(仅 antigravity 平台使用) AllowImageGeneration bool AllowBatchImageGeneration bool @@ -316,20 +315,19 @@ type CreateGroupInput struct { } type UpdateGroupInput struct { - Name string - Description *string - I18n *GroupI18n - Platform string - RateMultiplier *float64 // 使用指针以支持设置为0 - MixedProviderRateMultipliers map[string]float64 // nil preserves; {} resets Mixed vendor rates to 1x - IsExclusive *bool - Status string - SubscriptionType string // standard/subscription - DailyLimitUSD *float64 // 日限额 (USD) - WeeklyLimitUSD *float64 // 周限额 (USD) - MonthlyLimitUSD *float64 // 月限额 (USD) - LongContextPricingEnabled *bool - ModelPricing *[]ChannelModelPricing + I18n *GroupI18n + Name string + Description *string + Platform string + RateMultiplier *float64 // 使用指针以支持设置为0 + IsExclusive *bool + Status string + SubscriptionType string // standard/subscription + DailyLimitUSD *float64 // 日限额 (USD) + WeeklyLimitUSD *float64 // 周限额 (USD) + MonthlyLimitUSD *float64 // 月限额 (USD) + LongContextPricingEnabled *bool + ModelPricing *[]ChannelModelPricing // 图片生成计费配置(仅 antigravity 平台使用) AllowImageGeneration *bool AllowBatchImageGeneration *bool diff --git a/backend/internal/service/admin_user.go b/backend/internal/service/admin_user.go index b38994e..d248022 100644 --- a/backend/internal/service/admin_user.go +++ b/backend/internal/service/admin_user.go @@ -215,7 +215,7 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda if err := validateUserSchedulingLimits(input.Concurrency, input.RPMLimit); err != nil { return nil, err } - // nil clears an override; an explicit zero is allowed only for Mixed groups. + // nil clears an override; native user-specific rates must remain positive. if input.GroupRates != nil { for groupID, rate := range input.GroupRates { if rate == nil { @@ -224,11 +224,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda if err := validateUserGroupRateMultiplier(*rate); err != nil { return nil, fmt.Errorf("%w (group_id=%d)", err, groupID) } - if *rate == 0 { - if err := s.validateZeroUserGroupRate(ctx, groupID); err != nil { - return nil, err - } - } } } @@ -380,23 +375,6 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda return user, nil } -func (s *adminServiceImpl) validateZeroUserGroupRate(ctx context.Context, groupID int64) error { - if groupID <= 0 { - return fmt.Errorf("cannot validate zero rate_multiplier (group_id=%d): %w", groupID, ErrGroupNotFound) - } - if s.groupRepo == nil { - return fmt.Errorf("cannot validate zero rate_multiplier: group repository unavailable (group_id=%d)", groupID) - } - group, err := s.groupRepo.GetByID(ctx, groupID) - if err != nil { - return fmt.Errorf("cannot validate zero rate_multiplier (group_id=%d): %w", groupID, err) - } - if group == nil || group.Platform != PlatformMixed { - return fmt.Errorf("rate_multiplier must be > 0 for ordinary groups (group_id=%d)", groupID) - } - return nil -} - func sameInt64Set(a, b []int64) bool { if len(a) != len(b) { return false diff --git a/backend/internal/service/admin_user_group_rate_validation_test.go b/backend/internal/service/admin_user_group_rate_validation_test.go index d954a76..f52f522 100644 --- a/backend/internal/service/admin_user_group_rate_validation_test.go +++ b/backend/internal/service/admin_user_group_rate_validation_test.go @@ -30,6 +30,49 @@ func (r *adminUserGroupRateCaptureRepo) SyncGroupRateMultipliers(_ context.Conte return nil } +func TestAdminService_UpdateUser_NilGroupRateStillClears(t *testing.T) { + repo := &adminUserGroupRateCaptureRepo{} + svc := &adminServiceImpl{userRepo: &rpmUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 42}}}, userGroupRateRepo: repo} + _, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{GroupRates: map[int64]*float64{10: nil}}) + require.NoError(t, err) + require.Equal(t, int64(42), repo.userID) + require.Contains(t, repo.rates, int64(10)) + require.Nil(t, repo.rates[10]) +} + +func TestAdminService_BatchInvalidRateStillRejectedWhenRateRepoUnavailable(t *testing.T) { + svc := &adminServiceImpl{} + err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, []GroupRateMultiplierInput{{UserID: 42, RateMultiplier: -1}}) + require.Error(t, err) +} + +func TestAdminService_UserGroupRateWritesRefreshBothGateways(t *testing.T) { + ctx := context.Background() + repo := &revisionUserGroupRateRepo{} + svc := &adminServiceImpl{ + userRepo: &rpmUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 42}}}, + groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Platform: PlatformOpenAI}}, + userGroupRateRepo: repo, + } + gateway := &GatewayService{userGroupRateResolver: newUserGroupRateResolver(repo, nil, time.Minute, nil, "test")} + openai := &OpenAIGatewayService{userGroupRateResolver: newUserGroupRateResolver(repo, nil, time.Minute, nil, "test")} + assertBoth := func(want float64) { + t.Helper() + require.Equal(t, want, gateway.ResolveUserGroupRateMultiplier(ctx, 42, 10, 1.5)) + require.Equal(t, want, openai.ResolveUserGroupRateMultiplier(ctx, 42, 10, 1.5)) + } + assertBoth(1.5) // Cache the absence of a user override in both gateways. + rate := 0.5 + _, err := svc.UpdateUser(ctx, 42, &UpdateUserInput{GroupRates: map[int64]*float64{10: &rate}}) + require.NoError(t, err) + assertBoth(0.5) + require.NoError(t, svc.BatchSetGroupRateMultipliers(ctx, 10, []GroupRateMultiplierInput{{UserID: 42, RateMultiplier: 2}})) + assertBoth(2) + _, err = svc.UpdateUser(ctx, 42, &UpdateUserInput{GroupRates: map[int64]*float64{10: nil}}) + require.NoError(t, err) + assertBoth(1.5) +} + func TestAdminService_UserGroupRateValidation(t *testing.T) { for _, tc := range []struct { name string @@ -38,16 +81,15 @@ func TestAdminService_UserGroupRateValidation(t *testing.T) { groupErr error missing, unavailable, wantErr bool }{ - {name: "mixed zero", platform: PlatformMixed, rate: 0}, - {name: "mixed positive", platform: PlatformMixed, rate: 0.5}, + {name: "ordinary fractional", platform: PlatformOpenAI, rate: 0.5}, {name: "ordinary zero", platform: PlatformOpenAI, rate: 0, wantErr: true}, {name: "ordinary positive", platform: PlatformOpenAI, rate: 0.5}, - {name: "maximum", platform: PlatformMixed, rate: maxUserGroupRateMultiplier}, - {name: "overflow", platform: PlatformMixed, rate: 1000000, wantErr: true}, - {name: "negative", platform: PlatformMixed, rate: -1, wantErr: true}, - {name: "nan", platform: PlatformMixed, rate: math.NaN(), wantErr: true}, - {name: "positive infinity", platform: PlatformMixed, rate: math.Inf(1), wantErr: true}, - {name: "negative infinity", platform: PlatformMixed, rate: math.Inf(-1), wantErr: true}, + {name: "maximum", platform: PlatformOpenAI, rate: maxUserGroupRateMultiplier}, + {name: "overflow", platform: PlatformOpenAI, rate: 1000000, wantErr: true}, + {name: "negative", platform: PlatformOpenAI, rate: -1, wantErr: true}, + {name: "nan", platform: PlatformOpenAI, rate: math.NaN(), wantErr: true}, + {name: "positive infinity", platform: PlatformOpenAI, rate: math.Inf(1), wantErr: true}, + {name: "negative infinity", platform: PlatformOpenAI, rate: math.Inf(-1), wantErr: true}, {name: "zero missing group", rate: 0, missing: true, wantErr: true}, {name: "zero group lookup failure", rate: 0, groupErr: errors.New("offline"), wantErr: true}, {name: "zero unavailable group repository", rate: 0, unavailable: true, wantErr: true}, @@ -89,46 +131,3 @@ func TestAdminService_UserGroupRateValidation(t *testing.T) { } } } - -func TestAdminService_UpdateUser_NilGroupRateStillClears(t *testing.T) { - repo := &adminUserGroupRateCaptureRepo{} - svc := &adminServiceImpl{userRepo: &rpmUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 42}}}, userGroupRateRepo: repo} - _, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{GroupRates: map[int64]*float64{10: nil}}) - require.NoError(t, err) - require.Equal(t, int64(42), repo.userID) - require.Contains(t, repo.rates, int64(10)) - require.Nil(t, repo.rates[10]) -} - -func TestAdminService_BatchZeroRateDoesNotSkipGroupValidationWhenRateRepoUnavailable(t *testing.T) { - svc := &adminServiceImpl{} - err := svc.BatchSetGroupRateMultipliers(context.Background(), 10, []GroupRateMultiplierInput{{UserID: 42, RateMultiplier: 0}}) - require.Error(t, err) -} - -func TestAdminService_UserGroupRateWritesRefreshBothGateways(t *testing.T) { - ctx := context.Background() - repo := &revisionUserGroupRateRepo{} - svc := &adminServiceImpl{ - userRepo: &rpmUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 42}}}, - groupRepo: &groupRepoStubForAdmin{getByID: &Group{ID: 10, Platform: PlatformMixed}}, - userGroupRateRepo: repo, - } - gateway := &GatewayService{userGroupRateResolver: newUserGroupRateResolver(repo, nil, time.Minute, nil, "test")} - openai := &OpenAIGatewayService{userGroupRateResolver: newUserGroupRateResolver(repo, nil, time.Minute, nil, "test")} - assertBoth := func(want float64) { - t.Helper() - require.Equal(t, want, gateway.ResolveUserGroupRateMultiplier(ctx, 42, 10, 1.5)) - require.Equal(t, want, openai.ResolveUserGroupRateMultiplier(ctx, 42, 10, 1.5)) - } - assertBoth(1.5) // Cache the absence of a user override in both gateways. - zero := 0.0 - _, err := svc.UpdateUser(ctx, 42, &UpdateUserInput{GroupRates: map[int64]*float64{10: &zero}}) - require.NoError(t, err) - assertBoth(0) - require.NoError(t, svc.BatchSetGroupRateMultipliers(ctx, 10, []GroupRateMultiplierInput{{UserID: 42, RateMultiplier: 2}})) - assertBoth(2) - _, err = svc.UpdateUser(ctx, 42, &UpdateUserInput{GroupRates: map[int64]*float64{10: nil}}) - require.NoError(t, err) - assertBoth(1.5) -} diff --git a/backend/internal/service/anthropic_apikey_auth.go b/backend/internal/service/anthropic_apikey_auth.go index f58f64a..8644c58 100644 --- a/backend/internal/service/anthropic_apikey_auth.go +++ b/backend/internal/service/anthropic_apikey_auth.go @@ -21,7 +21,7 @@ func (a *Account) GetAnthropicAPIKeyAuthScheme() string { if a == nil || a.Type != AccountTypeAPIKey { return AnthropicAPIKeyAuthSchemeXAPIKey } - if a.Platform != PlatformAnthropic && !a.IsMultiProtocolAPIKey() && !genericOpenAIProtocolFallbackAccount(a) { + if a.Platform != PlatformAnthropic && !a.IsMultiProtocolAPIKey() { return AnthropicAPIKeyAuthSchemeXAPIKey } @@ -49,7 +49,7 @@ func isOllamaCloudAnthropicAuthBaseURL(baseURL string) bool { // 其它上游保持历史 extra/default 行为。baseURL 为该请求实际选用的 Anthropic // 上游 base(GetBaseURL / GetAnthropicProtocolBaseURL 等),默认官方端点时传空。 func setAnthropicAPIKeyAuthHeader(header http.Header, account *Account, token, baseURL string) { - if account.Type == AccountTypeAPIKey && !account.IsMixed() && isOllamaCloudAnthropicAuthBaseURL(baseURL) { + if account.Type == AccountTypeAPIKey && isOllamaCloudAnthropicAuthBaseURL(baseURL) { header.Set("Authorization", "Bearer "+token) return } diff --git a/backend/internal/service/anthropic_messages_validation_test.go b/backend/internal/service/anthropic_messages_validation_test.go index 0c640df..7dbc68a 100644 --- a/backend/internal/service/anthropic_messages_validation_test.go +++ b/backend/internal/service/anthropic_messages_validation_test.go @@ -48,6 +48,35 @@ func TestSelectedOpusMessagesOriginalFactsAndMapping(t *testing.T) { require.NoError(t, validateSelectedAnthropicMessagesRequest(nil, []byte(`{}`), "deepseek-v4")) } +func TestClaude55BufferedResponsePreservesSignatureAtServiceBoundary(t *testing.T) { + const sse = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"model\":\"alias\",\"content\":[],\"usage\":{\"input_tokens\":1}}}\n\n" + + "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n" + + "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"signed\"}}\n\n" + + "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + + "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n" + + "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" + for _, native := range []bool{false, true} { + t.Run(map[bool]string{false: "anthropic", true: "openai-native"}[native], func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + resp := &http.Response{Header: http.Header{}, Body: io.NopCloser(strings.NewReader(sse))} + cfg := &config.Config{} + if native { + svc := &OpenAIGatewayService{cfg: cfg} + _, err := svc.handleResponsesBufferedFromNativeAnthropic(resp, c, "alias", "claude-haiku-5-5", "claude-haiku-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + require.NoError(t, err) + } else { + svc := &GatewayService{cfg: cfg} + _, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "alias", "claude-haiku-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) + require.NoError(t, err) + } + require.Contains(t, rec.Body.String(), "anthropic-thinking-v1:") + require.Contains(t, rec.Body.String(), `"model":"alias"`) + }) + } +} + func TestMappedOpusValidationBeforeUpstreamOnBothMessagesPaths(t *testing.T) { for _, native := range []bool{false, true} { for _, pass := range []bool{false, true} { @@ -69,7 +98,7 @@ func TestMappedOpusValidationBeforeUpstreamOnBothMessagesPaths(t *testing.T) { }, Extra: map[string]any{"anthropic_passthrough": pass}} var err error if native { - account.Platform = PlatformMixed + account.Platform = PlatformCommandCode _, err = (&OpenAIGatewayService{}).forwardAnthropicViaNativeAnthropicEndpoint(context.Background(), c, account, body, "") } else { _, err = (&GatewayService{}).Forward(context.Background(), c, account, &ParsedRequest{Model: "public-alias", Body: NewRequestBodyRef(body)}) @@ -83,32 +112,3 @@ func TestMappedOpusValidationBeforeUpstreamOnBothMessagesPaths(t *testing.T) { } } } - -func TestClaude55BufferedResponsePreservesSignatureAtServiceBoundary(t *testing.T) { - const sse = "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"m\",\"model\":\"alias\",\"content\":[],\"usage\":{\"input_tokens\":1}}}\n\n" + - "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\n" + - "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"signed\"}}\n\n" + - "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n" + - "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n" + - "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" - for _, native := range []bool{false, true} { - t.Run(map[bool]string{false: "anthropic", true: "openai-native"}[native], func(t *testing.T) { - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) - resp := &http.Response{Header: http.Header{}, Body: io.NopCloser(strings.NewReader(sse))} - cfg := &config.Config{} - if native { - svc := &OpenAIGatewayService{cfg: cfg} - _, err := svc.handleResponsesBufferedFromNativeAnthropic(resp, c, "alias", "claude-haiku-5-5", "claude-haiku-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}, true) - require.NoError(t, err) - } else { - svc := &GatewayService{cfg: cfg} - _, err := svc.handleResponsesBufferedStreamingResponse(resp, c, "alias", "claude-haiku-5-5", nil, time.Now(), apicompat.ResponsesClientToolMapping{}) - require.NoError(t, err) - } - require.Contains(t, rec.Body.String(), "anthropic-thinking-v1:") - require.Contains(t, rec.Body.String(), `"model":"alias"`) - }) - } -} diff --git a/backend/internal/service/anthropic_upstream_transport_error.go b/backend/internal/service/anthropic_upstream_transport_error.go index 1a637dc..fb71890 100644 --- a/backend/internal/service/anthropic_upstream_transport_error.go +++ b/backend/internal/service/anthropic_upstream_transport_error.go @@ -39,11 +39,6 @@ func (s *GatewayService) handleAnthropicUpstreamTransportError( passthrough bool, err error, ) error { - // A local policy refusal never reached the upstream and must retain its - // 403 identity without recording an outage or starting account failover. - if errors.Is(err, ErrModelScopeDenied) { - return err - } if err == nil { return nil } diff --git a/backend/internal/service/anthropic_upstream_transport_error_test.go b/backend/internal/service/anthropic_upstream_transport_error_test.go index 5cc5c09..6049b06 100644 --- a/backend/internal/service/anthropic_upstream_transport_error_test.go +++ b/backend/internal/service/anthropic_upstream_transport_error_test.go @@ -3,7 +3,6 @@ package service import ( "context" "errors" - "fmt" "net/http" "net/http/httptest" "testing" @@ -35,37 +34,6 @@ func newAnthropicTransportErrorTestContext() (*gin.Context, *httptest.ResponseRe return c, rec } -func TestHandleAnthropicUpstreamTransportError_ModelScopeDenialHasNoFailureEffects(t *testing.T) { - for _, policyErr := range []error{ErrModelScopeDenied, fmt.Errorf("transport wrapper: %w", ErrModelScopeDenied)} { - c, rec := newAnthropicTransportErrorTestContext() - repo := &anthropicTransportErrorRepoStub{} - cache := &failedAffinityCacheStub{} - svc := &GatewayService{accountRepo: repo, cache: cache} - account := newAnthropicAPIKeyAccountForTest() - ctx := withUpstreamAttempts(context.Background()) - err := svc.handleAnthropicUpstreamTransportError(ctx, c, account, "https://api.anthropic.com/v1/messages", true, policyErr) - require.Same(t, policyErr, err) - require.ErrorIs(t, err, ErrModelScopeDenied) - var failoverErr *UpstreamFailoverError - require.False(t, errors.As(err, &failoverErr)) - require.Zero(t, repo.calls) - for _, key := range []string{OpsUpstreamStatusCodeKey, OpsUpstreamErrorMessageKey, OpsUpstreamErrorsKey} { - _, exists := c.Get(key) - require.False(t, exists, "local scope denial must not record an upstream failure") - } - svc.InvalidateUpstreamAffinity(ctx, nil, "session", account, err) - require.Empty(t, cache.invalidated) - attempts, ok := ctx.Value(upstreamAttemptContextKey{}).(*upstreamAttempts) - require.True(t, ok) - require.Empty(t, attempts.failed) - require.Equal(t, http.StatusOK, rec.Code) - require.Empty(t, rec.Body.String()) - // Denial must precede all service, health, and activity access. - var nilService *GatewayService - require.Same(t, policyErr, nilService.handleAnthropicUpstreamTransportError(context.Background(), nil, account, "", false, policyErr)) - } -} - func TestHandleAnthropicUpstreamTransportError_PersistentFailsOverAndUnschedules(t *testing.T) { c, rec := newAnthropicTransportErrorTestContext() repo := &anthropicTransportErrorRepoStub{} diff --git a/backend/internal/service/antigravity_gateway_retry.go b/backend/internal/service/antigravity_gateway_retry.go index 7b6f7bd..d0951c5 100644 --- a/backend/internal/service/antigravity_gateway_retry.go +++ b/backend/internal/service/antigravity_gateway_retry.go @@ -537,9 +537,6 @@ urlFallbackLoop: } resp, err = p.httpUpstream.Do(upstreamReq, p.proxyURL, p.account.ID, p.account.Concurrency) - if errors.Is(err, ErrModelScopeDenied) { - return nil, err - } if err == nil && resp == nil { err = errors.New("upstream returned nil response") } diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 182ce81..399d52d 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -161,7 +161,7 @@ func NewAntigravityGatewayService( accountRepo: accountRepo, tokenProvider: tokenProvider, rateLimitService: rateLimitService, - httpUpstream: withModelScopeUpstream(httpUpstream), + httpUpstream: httpUpstream, settingService: settingService, cache: cache, schedulerSnapshot: schedulerSnapshot, diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index a4c7330..4bd2305 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "log/slog" + "maps" "math/rand/v2" "time" @@ -14,7 +15,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 27 // v27: Mixed absolute provider rates +const apiKeyAuthSnapshotVersion = 28 // v28: native platforms; retired scopes are rejected at authentication type apiKeyAuthCacheConfig struct { l1Size int @@ -401,7 +402,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) Status: apiKey.Group.Status, SubscriptionType: apiKey.Group.SubscriptionType, RateMultiplier: apiKey.Group.RateMultiplier, - MixedProviderRateMultipliers: CloneMixedProviderRateMultipliers(apiKey.Group.MixedProviderRateMultipliers), + MixedProviderRateMultipliers: maps.Clone(apiKey.Group.MixedProviderRateMultipliers), DailyLimitUSD: apiKey.Group.DailyLimitUSD, WeeklyLimitUSD: apiKey.Group.WeeklyLimitUSD, MonthlyLimitUSD: apiKey.Group.MonthlyLimitUSD, @@ -511,7 +512,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho Hydrated: true, SubscriptionType: snapshot.Group.SubscriptionType, RateMultiplier: snapshot.Group.RateMultiplier, - MixedProviderRateMultipliers: CloneMixedProviderRateMultipliers(snapshot.Group.MixedProviderRateMultipliers), + MixedProviderRateMultipliers: maps.Clone(snapshot.Group.MixedProviderRateMultipliers), DailyLimitUSD: snapshot.Group.DailyLimitUSD, WeeklyLimitUSD: snapshot.Group.WeeklyLimitUSD, MonthlyLimitUSD: snapshot.Group.MonthlyLimitUSD, diff --git a/backend/internal/service/api_key_legacy_fields.go b/backend/internal/service/api_key_legacy_fields.go new file mode 100644 index 0000000..88f6285 --- /dev/null +++ b/backend/internal/service/api_key_legacy_fields.go @@ -0,0 +1,12 @@ +package service + +// ModelScopeAll is retained only for the historical database column. +// API keys are authorized by their native groups and model allowlists. +const ModelScopeAll = "all" + +func NormalizeModelScope(scope string) string { + if scope == "" { + return ModelScopeAll + } + return scope +} diff --git a/backend/internal/service/api_key_model_scope.go b/backend/internal/service/api_key_model_scope.go deleted file mode 100644 index 7e7bc6d..0000000 --- a/backend/internal/service/api_key_model_scope.go +++ /dev/null @@ -1,175 +0,0 @@ -package service - -import ( - "context" - "strings" - - "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" -) - -const ( - ModelScopeAll = "all" - ModelScopeOpenAI = "openai" - ModelScopeAnthropic = "anthropic" - ModelScopeCN = "cn" - ModelScopeOther = "other" -) - -// Includes the domestic families already offered by the account model picker. -// Exact numbered aliases are separate so k1/k2 and m2 cannot match arbitrary -// unrelated names; pricing vendors share this same classifier. -var cnModelScopeFamilies = []string{"qwen", "qwq", "glm", "chatglm", "cogview", "cogvideo", "deepseek", "kimi", "moonshot", "minimax", "mimo", "step", "abab", "doubao", "yi", "ernie", "spark", "hunyuan"} - -func bareCNModelAliasVendor(model string) string { - if model == "k3" || model == "k3-256k" || modelFamily(model, "k2") || modelFamily(model, "k1.5") { - return "kimi" - } - if strings.HasPrefix(model, "m2.") { - return "minimax" - } - return "" -} - -var openAIModelScopeFamilies = []string{"gpt", "chatgpt", "codex", "o1", "o3", "o4", "o5", "text-embedding", "text-moderation", "omni-moderation", "dall-e", "tts", "whisper", "babbage", "davinci"} - -var ErrInvalidModelScope = infraerrors.BadRequest("INVALID_MODEL_SCOPE", "model_scope must be all, openai, anthropic, cn or other") -var ErrModelScopeDenied = infraerrors.Forbidden("MODEL_SCOPE_DENIED", "Model is not available for this API key provider category") - -// Only the legacy empty value is normalized. Unknown persisted values must fail -// closed, including values written by a newer binary during a rolling upgrade. -func NormalizeModelScope(scope string) string { - if scope == "" { - return ModelScopeAll - } - return scope -} - -func ValidModelScope(scope string) bool { - switch NormalizeModelScope(scope) { - case ModelScopeAll, ModelScopeOpenAI, ModelScopeAnthropic, ModelScopeCN, ModelScopeOther: - return true - } - return false -} - -// ClassifyModelScope classifies names without changing the routed model or its -// pricing identity. A namespace is trusted only for its own model families. -func ClassifyModelScope(model string) string { - model = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(model)), "models/") - if bareCNModelAliasVendor(model) != "" { - return ModelScopeCN - } - // Canonical Bedrock Claude invocation IDs retain their provider namespace. - for _, prefix := range []string{"anthropic.", "us.anthropic.", "eu.anthropic.", "apac.anthropic.", "global.anthropic."} { - if strings.HasPrefix(model, prefix+"claude-") { - return ModelScopeAnthropic - } - } - if namespace, family, ok := strings.Cut(model, "/"); ok { - if strings.Contains(family, "/") { - return ModelScopeOther - } - allowed := map[string][]string{ - "openai": openAIModelScopeFamilies, - "chatgpt": openAIModelScopeFamilies, - "anthropic": {"claude", "opus", "sonnet", "haiku"}, - "qwen": {"qwen", "qwq"}, "z-ai": {"glm", "chatglm", "cogview", "cogvideo"}, "zhipuai": {"glm", "chatglm", "cogview", "cogvideo"}, "zhipu": {"glm", "chatglm", "cogview", "cogvideo"}, - "deepseek-ai": {"deepseek"}, "deepseek": {"deepseek"}, - "moonshotai": {"kimi", "moonshot"}, "moonshot": {"kimi", "moonshot"}, - "kimi": {"kimi", "moonshot"}, "glm": {"glm", "chatglm", "cogview", "cogvideo"}, "bigmodel": {"glm", "chatglm", "cogview", "cogvideo"}, "thudm": {"glm", "chatglm", "cogview", "cogvideo"}, - "minimaxai": {"minimax"}, "minimax": {"minimax"}, - "xiaomi": {"mimo"}, "xiaomimimo": {"mimo"}, "stepfun-ai": {"step"}, "stepfun": {"step"}, - "bytedance": {"doubao"}, "doubao": {"doubao"}, "volcengine": {"doubao"}, - "01-ai": {"yi"}, "01ai": {"yi"}, "baidu": {"ernie"}, "iflytek": {"spark"}, "tencent": {"hunyuan"}, - } - matched := false - for _, prefix := range allowed[namespace] { - if modelFamily(family, prefix) { - matched = true - break - } - } - if !matched { - if _, known := allowed[namespace]; known { - return "" - } - return ModelScopeOther - } - model = family - } - for _, family := range openAIModelScopeFamilies { - if modelFamily(model, family) { - return ModelScopeOpenAI - } - } - for _, family := range []string{"claude", "opus", "sonnet", "haiku"} { - if modelFamily(model, family) { - return ModelScopeAnthropic - } - } - for _, family := range cnModelScopeFamilies { - if modelFamily(model, family) { - return ModelScopeCN - } - } - return ModelScopeOther -} - -func modelFamily(model, family string) bool { - if model == family { - return true - } - if !strings.HasPrefix(model, family) || len(model) <= len(family) { - return false - } - c := model[len(family)] - // o1/o3/o4/o5 are complete numbered families: o10 and o31 are not o1/o3. - return c == '-' || c == '_' || c == '.' || (family[0] != 'o' && c >= '0' && c <= '9') -} - -func ModelScopeAllows(scope, model string) bool { - scope = NormalizeModelScope(scope) - if scope == ModelScopeAll { - return true - } - return ValidModelScope(scope) && strings.TrimSpace(model) != "" && - !strings.ContainsAny(model, "*?") && ClassifyModelScope(model) == scope -} - -func (k *APIKey) AllowsModelScope(model string) bool { - return k == nil || ModelScopeAllows(k.ModelScope, model) -} - -func ContextWithModelScope(ctx context.Context, scope string) context.Context { - return context.WithValue(ctx, ctxkey.APIKeyModelScope, NormalizeModelScope(scope)) -} - -func ModelScopeFromContext(ctx context.Context) string { - if ctx == nil { - return ModelScopeAll - } - scope, _ := ctx.Value(ctxkey.APIKeyModelScope).(string) - return NormalizeModelScope(scope) -} - -func CheckModelScope(ctx context.Context, models ...string) error { - for _, model := range models { - if !ModelScopeAllows(ModelScopeFromContext(ctx), model) { - return ErrModelScopeDenied - } - } - return nil -} - -// FilterModelScope always creates a private slice: callers may share their -// input with the group catalogue cache and other keys. -func FilterModelScope(scope string, models []string) []string { - result := make([]string, 0, len(models)) - for _, model := range models { - if ModelScopeAllows(scope, model) { - result = append(result, model) - } - } - return result -} diff --git a/backend/internal/service/api_key_model_scope_test.go b/backend/internal/service/api_key_model_scope_test.go deleted file mode 100644 index 50cda3a..0000000 --- a/backend/internal/service/api_key_model_scope_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "io" - "net/http" - "strings" - "testing" - "time" - - gocache "github.com/patrickmn/go-cache" - "github.com/stretchr/testify/require" -) - -func TestModelScopeClassifier(t *testing.T) { - cases := map[string]string{ - " Models/OpenAI/GPT-5.5 ": "openai", "chatgpt-4o": "openai", "codex-mini": "openai", "o1": "openai", "o3-mini": "openai", "o4-mini": "openai", "o5": "openai", - "o10": "other", "o31": "other", "foo/gpt-5": "other", "openai/claude-4": "", "qwen/gpt-5": "", "models/models/gpt-5": "other", - "text-embedding-3-large": "openai", "openai/whisper-1": "openai", "tts-1-hd": "openai", "dall-e-3": "openai", "chatgpt/gpt5.4": "openai", - "anthropic/claude-opus-4": "anthropic", "opus-4": "anthropic", "sonnet-4": "anthropic", "haiku-4": "anthropic", - "qwen/qwen3.8-27b": "cn", "qwq-32b": "cn", "z-ai/glm-5.3": "cn", "zhipuai/glm-5": "cn", "deepseek-ai/deepseek-v4": "cn", "deepseek/deepseek-chat": "cn", - "moonshotai/kimi-k2.6": "cn", "minimaxai/minimax-m2": "cn", "xiaomi/mimo-v2": "cn", "xiaomimimo/mimo-v2": "cn", "stepfun-ai/step-3": "cn", "k3-256k": "cn", "abab6.5-chat": "cn", - "glmish": "other", "deepseekx": "other", "custom/deepseek-chat": "other", "z-ai/qwen3": "", "gemini-3": "other", "grok-4": "other", "opaque-final": "other", - "chatglm_turbo": "cn", "zhipuai/chatglm_pro": "cn", "cogview-3": "cn", "thudm/cogvideo": "cn", "doubao-embedding-vision": "cn", - "bytedance/doubao-pro-32k": "cn", "yi-large": "cn", "01-ai/yi-1.5": "cn", "ernie-4.0": "cn", "baidu/ernie-speed": "cn", - "spark-desk": "cn", "iflytek/spark-pro": "cn", "hunyuan-turbo": "cn", "tencent/hunyuan-code": "cn", "k2-thinking": "cn", "k1.5-long": "cn", "m2.7": "cn", - "chatglmx": "other", "cognitive": "other", "doubaox": "other", "01-ai/gpt-5": "", "unknown/doubao-pro": "other", - } - for model, expected := range cases { - require.Equal(t, expected, ClassifyModelScope(model), model) - } - for _, model := range []string{"gpt-5", "claude-4", "qwen3", "grok-4"} { - count := 0 - for _, scope := range []string{"openai", "anthropic", "cn", "other"} { - if ModelScopeAllows(scope, model) { - count++ - } - } - require.Equal(t, 1, count) - require.True(t, ModelScopeAllows("", model)) - require.False(t, ModelScopeAllows("future", model)) - } - require.False(t, ModelScopeAllows("cn", "qwen*")) - require.ErrorIs(t, validateCreateAPIKeyRequest(CreateAPIKeyRequest{ModelScope: "CN"}), ErrInvalidModelScope) -} - -func TestAPIKeyModelScopeAuthSnapshotRoundtrip(t *testing.T) { - svc := &APIKeyService{} - for _, scope := range []string{"", "all", "openai", "anthropic", "cn", "other", "unknown"} { - key := &APIKey{ModelScope: scope, User: &User{ID: 1}} - snapshot := svc.snapshotFromAPIKey(context.Background(), key) - require.Equal(t, apiKeyAuthSnapshotVersion, snapshot.Version) - payload, err := json.Marshal(snapshot) - require.NoError(t, err) - var decoded APIKeyAuthSnapshot - require.NoError(t, json.Unmarshal(payload, &decoded)) - require.Equal(t, scope, svc.snapshotToAPIKey("secret", &decoded).ModelScope) - } - _, used, err := svc.applyAuthCacheEntry("secret", &APIKeyAuthCacheEntry{Snapshot: &APIKeyAuthSnapshot{Version: 25}}) - require.NoError(t, err) - require.False(t, used) -} - -type modelScopeKeyRepo struct { - APIKeyRepository - key APIKey - fields APIKeyUpdateFields -} - -func (r *modelScopeKeyRepo) GetByID(context.Context, int64) (*APIKey, error) { - copyKey := r.key - return ©Key, nil -} -func (r *modelScopeKeyRepo) Update(_ context.Context, key *APIKey, fields APIKeyUpdateFields) error { - r.fields = fields - r.key = *key - return nil -} - -func TestAPIKeyModelScopeUpdateOmittedAndExplicit(t *testing.T) { - for _, scope := range []string{"all", "cn"} { - repo := &modelScopeKeyRepo{key: APIKey{ID: 1, UserID: 1, ModelScope: scope}} - svc := &APIKeyService{apiKeyRepo: repo} - name := "renamed" - got, err := svc.Update(context.Background(), 1, 1, UpdateAPIKeyRequest{Name: &name}) - require.NoError(t, err) - require.Equal(t, scope, got.ModelScope) - require.Equal(t, APIKeyUpdateFields{Name: true}, repo.fields) - selected := "openai" - got, err = svc.Update(context.Background(), 1, 1, UpdateAPIKeyRequest{ModelScope: &selected}) - require.NoError(t, err) - require.Equal(t, selected, got.ModelScope) - require.Equal(t, APIKeyUpdateFields{ModelScope: true}, repo.fields) - invalid := "future" - _, err = svc.Update(context.Background(), 1, 1, UpdateAPIKeyRequest{ModelScope: &invalid}) - require.ErrorIs(t, err, ErrInvalidModelScope) - } -} - -type modelScopeAccountRepo struct { - AccountRepository - accounts []Account - calls int -} - -func (r *modelScopeAccountRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) { - r.calls++ - return r.accounts, nil -} - -func TestMixedSupportedKeyProvidersCatalogue(t *testing.T) { - for _, tc := range []struct { - name string - mapping map[string]any - allow []string - want []string - }{ - {"four", map[string]any{"gpt-5": "gpt-5", "claude-4": "claude-4", "qwen3": "qwen3", "gemini-3": "gemini-3", "grok-4": "grok-4"}, nil, []string{"openai", "anthropic", "cn", "other"}}, - {"partial", map[string]any{"qwen3": "qwen3"}, nil, []string{"cn"}}, - {"existing domestic families", map[string]any{"chatglm_turbo": "chatglm_turbo", "cogview-3": "cogview-3", "doubao-pro-32k": "doubao-pro-32k", "ernie-4.0": "ernie-4.0"}, nil, []string{"cn"}}, - {"empty", map[string]any{}, nil, []string{}}, - {"allowlist", map[string]any{"qwen3": "qwen3", "gpt-5": "gpt-5"}, []string{"qwen3"}, []string{"cn"}}, - {"cross mappings", map[string]any{"gpt-5": "qwen3", "claude-4": "opaque-final", "alias": "gpt-5", "qwen*": "qwen3"}, nil, []string{}}, - } { - t.Run(tc.name, func(t *testing.T) { - repo := &modelScopeAccountRepo{accounts: []Account{{Platform: PlatformMixed, Credentials: map[string]any{"model_mapping": tc.mapping}}}} - svc := &GatewayService{accountRepo: repo, modelsListCache: gocache.New(time.Minute, time.Minute), modelsListCacheTTL: time.Minute} - group := &Group{ID: 10, Platform: PlatformMixed, ModelAllowlist: GroupModelAllowlist{Enabled: tc.allow != nil, Models: tc.allow}} - require.Equal(t, tc.want, svc.SupportedKeyProviders(context.Background(), group)) - require.Equal(t, tc.want, svc.SupportedKeyProviders(context.Background(), group)) - require.Equal(t, 1, repo.calls, "one shared catalogue read for all four categories") - }) - } -} - -func TestModelScopeFinalOutboundGuard(t *testing.T) { - for _, tc := range []struct { - scope, url, body string - denied bool - }{ - {"openai", "https://relay.test/v1/responses", `{"model":"gpt-5"}`, false}, - {"openai", "https://relay.test/v1/chat/completions", `{"model":"qwen3"}`, true}, - {"cn", "https://relay.test/v1/responses/compact", `{"model":"gpt-5"}`, true}, - {"cn", "https://relay.test/v1/chat/completions", `{"model":"chatglm_turbo"}`, false}, - {"other", "https://relay.test/v1/chat/completions", `{"model":"chatglm_turbo"}`, true}, - {"cn", "https://relay.test/v1/embeddings", `{"model":"doubao-embedding-vision"}`, false}, - {"other", "https://relay.test/v1/embeddings", `{"model":"doubao-embedding-vision"}`, true}, - {"cn", "https://relay.test/v1/messages/count_tokens", `{"model":"opaque-final"}`, true}, - {"anthropic", "https://relay.test/v1beta/models/gemini-3:generateContent", `{}`, true}, - {"anthropic", "https://relay.test/v1internal:generateContent", `{"model":"gemini-3","request":{}}`, true}, - {"openai", "https://relay.test/v1/responses", `{"model":"gpt-5","Model":"glm-5"}`, true}, - {"openai", "https://relay.test/v1/responses", `{"model":"gpt-5","session":{"model":"grok-4"}}`, true}, - {"all", "https://relay.test/v1/messages", `{"model":"anything"}`, false}, - } { - req, err := http.NewRequestWithContext(ContextWithModelScope(context.Background(), tc.scope), http.MethodPost, tc.url, strings.NewReader(tc.body)) - require.NoError(t, err) - req.Header.Set("Content-Type", "application/json") - err = CheckUpstreamModelScope(req) - if tc.denied { - require.ErrorIs(t, err, ErrModelScopeDenied, tc.body) - } else { - require.NoError(t, err) - } - body, err := io.ReadAll(req.Body) - require.NoError(t, err) - require.Equal(t, tc.body, string(body)) - } -} - -func TestAPIKeyScopeRouteContextAndClones(t *testing.T) { - id := int64(2) - key := &APIKey{ModelScope: "cn", GroupID: &id, Group: &Group{ID: id}} - clone := cloneAPIKeyWithGroupID(key, &Group{ID: 3}) - require.Equal(t, "cn", clone.ModelScope) - ctx := ContextWithAPIKeyRoute(context.Background(), clone) - require.Equal(t, "cn", ModelScopeFromContext(ctx)) - require.ErrorIs(t, CheckModelScope(ctx, "gpt-5"), ErrModelScopeDenied) -} - -func TestModelScopeCatalogueWildcardChannelAndExactPriority(t *testing.T) { - cat := groupModelsCatalog{scopeMappings: []modelScopeMapping{{platform: PlatformMixed, mapping: map[string]string{ - "gpt-*": "qwen3", "gpt-safe": "gpt-5", "gemini-*": "claude-4", - }}}} - require.Equal(t, []string{"gpt-safe"}, filterModelsForKeyScope(cat, "openai", []string{"gpt-5", "gpt-safe"})) - require.Empty(t, filterModelsForKeyScope(cat, "other", []string{"models/gemini-3"})) - require.Empty(t, filterModelsForKeyScopeMapped(cat, "openai", []string{"gpt-safe"}, func(string) string { return "qwen3" })) - // A channel can point at another alias in the same family, which then - // resolves through the account's wildcard to a different family. - require.Empty(t, filterModelsForKeyScopeMapped(cat, "openai", []string{"gpt-safe"}, func(string) string { return "gpt-other" })) - require.Equal(t, []string{"gpt-5", "gpt-safe"}, filterModelsForKeyScope(cat, "all", []string{"gpt-5", "gpt-safe"})) - cat.scopeMappings = append(cat.scopeMappings, modelScopeMapping{platform: PlatformMixed, mapping: map[string]string{"gpt-5": "gpt-5"}}) - require.Equal(t, []string{"gpt-5", "gpt-safe"}, filterModelsForKeyScope(cat, "openai", []string{"gpt-5", "gpt-safe"}), "one bad mapping must not hide a safe alternative") -} diff --git a/backend/internal/service/api_key_routes.go b/backend/internal/service/api_key_routes.go index 2a9af64..dbe0792 100644 --- a/backend/internal/service/api_key_routes.go +++ b/backend/internal/service/api_key_routes.go @@ -323,9 +323,6 @@ func (s *GatewayService) UpstreamPlatformForModel(ctx context.Context, apiKey *A if apiKey.Group != nil && apiKey.Group.Platform == PlatformTypeSafe && !apiKey.UsesRequestTargetPlatform() { return "", false } - if apiKey.Group != nil && apiKey.Group.Platform == PlatformMixed && !apiKey.UsesRequestTargetPlatform() { - return PlatformMixed, true - } ids := apiKey.CandidateGroupIDs() if len(ids) == 0 && apiKey.Group != nil && apiKey.Group.ID > 0 { ids = []int64{apiKey.Group.ID} @@ -440,8 +437,7 @@ func upstreamPlatformCatalogMatch(ctx context.Context, apiKey *APIKey, model str } // Match the existing candidate iterator: a channel alias does not // bypass authorization of either the public or mapped model. - if catalogModel == "" || !ValidModelScope(apiKey.ModelScope) || - !apiKey.AllowsModelScope(model) || !apiKey.AllowsModelScope(catalogModel) { + if catalogModel == "" { continue } } @@ -477,9 +473,6 @@ func ContextWithAPIKeyRoute(ctx context.Context, key *APIKey) context.Context { if ctx == nil { ctx = context.Background() } - if key != nil && NormalizeModelScope(key.ModelScope) != ModelScopeAll { - ctx = ContextWithModelScope(ctx, key.ModelScope) - } if key == nil || key.GroupID == nil || key.Group == nil || key.Group.ID != *key.GroupID { return ctx } @@ -585,7 +578,7 @@ func (r *apiKeyRouteIterator) loadNext() bool { continue } } - if routed == nil || routed.Group == nil || !groupAllowsRequestedModel(routed.Group, r.requestedModel) { + if routed == nil || routed.Group == nil || routed.Group.Platform == "mixed" || !groupAllowsRequestedModel(routed.Group, r.requestedModel) { continue } if OpenAIImageGenerationIntentFromContext(r.ctx) && !GroupAllowsImageGeneration(routed.Group) { @@ -602,12 +595,6 @@ func (r *apiKeyRouteIterator) loadNext() bool { } // Capability-only selection uses an empty model placeholder. The actual // Live/Realtime model is checked by its executor before upstream access. - if !ValidModelScope(routed.ModelScope) || - (r.requestedModel != "" && (!routed.AllowsModelScope(r.requestedModel) || !routed.AllowsModelScope(model))) || - (model != "" && !routed.AllowsModelScope(model)) { - r.err = ErrModelScopeDenied - continue - } cat := r.catalog(routeCtx, routed.GroupID) presence := catalogHasRequestedModel(cat, r.requestedModel) if presence != groupCatalogModelPresent && model != r.requestedModel && diff --git a/backend/internal/service/api_key_routes_test.go b/backend/internal/service/api_key_routes_test.go index 7a9a694..34f18f8 100644 --- a/backend/internal/service/api_key_routes_test.go +++ b/backend/internal/service/api_key_routes_test.go @@ -342,28 +342,6 @@ func TestUpstreamPlatformForModel_GeminiGroupOpenAIAccount(t *testing.T) { require.Equal(t, PlatformOpenAI, platform) } -func TestUpstreamPlatformForModel_PreservesCatalogPriority(t *testing.T) { - id := int64(1) - var accounts []Account - for i, platform := range []string{PlatformAnthropic, PlatformCommandCode, PlatformCline, PlatformMixed, PlatformGrok, PlatformOpenAI} { - accounts = append(accounts, Account{ID: int64(i + 1), Platform: platform, Credentials: map[string]any{ - "model_mapping": map[string]any{"opaque-model": "opaque-model", "grok-4.7": "grok-4.7"}, - }}) - } - svc := &GatewayService{accountRepo: &modelsListAccountRepoStub{byGroup: map[int64][]Account{id: accounts}}} - key := &APIKey{GroupID: &id, Group: &Group{ID: id, Platform: PlatformOpenAI}} - platform, ok := svc.UpstreamPlatformForModel(context.Background(), key, "opaque-model") - require.True(t, ok) - require.Equal(t, PlatformOpenAI, platform) - platform, ok = svc.UpstreamPlatformForModel(context.Background(), key, "grok-4.7") - require.True(t, ok) - require.Equal(t, PlatformGrok, platform) - key.Group.Platform = PlatformMixed - platform, ok = svc.UpstreamPlatformForModel(context.Background(), key, "opaque-model") - require.True(t, ok) - require.Equal(t, PlatformMixed, platform, "single Mixed groups retain their existing dispatch") -} - func TestOpenAIUpstreamPlatformForModel_RequiresAccountCatalog(t *testing.T) { primaryID := int64(1) for _, upstream := range []string{PlatformOpenAI, PlatformCommandCode, PlatformCline} { @@ -389,51 +367,6 @@ func TestOpenAIUpstreamPlatformForModel_RequiresAccountCatalog(t *testing.T) { } } -func TestUpstreamPlatformCatalogMatch_ChannelAliasKeepsAdmission(t *testing.T) { - for _, tc := range []struct { - name string - scope string - allowlist []string - mapped string - restricted bool - want bool - }{ - {name: "mapped model in catalog", mapped: "gpt-5.1", want: true}, - {name: "public allowlist retained", mapped: "gpt-5.1", allowlist: []string{"gpt-other"}}, - {name: "mapped scope retained", mapped: "claude-sonnet-4", scope: ModelScopeOpenAI}, - {name: "restricted alias", mapped: "gpt-5.1", restricted: true}, - {name: "empty mapping", mapped: ""}, - } { - t.Run(tc.name, func(t *testing.T) { - group := &Group{ID: 2, Platform: PlatformOpenAI} - if tc.allowlist != nil { - group.ModelAllowlist = GroupModelsListConfig{Enabled: true, Models: tc.allowlist} - } - key := &APIKey{GroupID: &group.ID, Group: group, ModelScope: tc.scope} - platform, matched, _ := upstreamPlatformCatalogMatch(context.Background(), key, "gpt-public", []int64{2}, - func(context.Context, int64) *Group { return group }, - func(_ context.Context, groupID *int64, platform string) []string { - require.Equal(t, group.ID, *groupID) - if platform == PlatformOpenAI { - return []string{tc.mapped} - } - return nil - }, - func(_ context.Context, groupID *int64, model string) (ChannelMappingResult, bool) { - require.Equal(t, group.ID, *groupID) - require.Equal(t, "gpt-public", model) - return ChannelMappingResult{Mapped: true, MappedModel: tc.mapped}, tc.restricted - }) - require.Equal(t, tc.want, matched) - if tc.want { - require.Equal(t, PlatformOpenAI, platform) - } else { - require.Empty(t, platform) - } - }) - } -} - func TestSkipKeyRouteForCatalog(t *testing.T) { require.True(t, skipKeyRouteForCatalog(groupCatalogModelAbsent, false)) require.True(t, skipKeyRouteForCatalog(groupCatalogModelAbsent, true)) @@ -565,3 +498,65 @@ func TestGroupPassthroughsRequestedModel(t *testing.T) { require.False(t, groupPassthroughsRequestedModel(allowlisted, "grok-4.7", map[string]struct{}{PlatformGrok: {}})) require.False(t, groupPassthroughsRequestedModel(openaiGroup, "grok-imagine-video-1.5", map[string]struct{}{PlatformOpenAI: {}})) } + +func TestUpstreamPlatformForModel_PreservesCatalogPriority(t *testing.T) { + id := int64(1) + var accounts []Account + for i, platform := range []string{PlatformAnthropic, PlatformCommandCode, PlatformCline, PlatformGrok, PlatformOpenAI} { + accounts = append(accounts, Account{ID: int64(i + 1), Platform: platform, Credentials: map[string]any{ + "model_mapping": map[string]any{"opaque-model": "opaque-model", "grok-4.7": "grok-4.7"}, + }}) + } + svc := &GatewayService{accountRepo: &modelsListAccountRepoStub{byGroup: map[int64][]Account{id: accounts}}} + key := &APIKey{GroupID: &id, Group: &Group{ID: id, Platform: PlatformOpenAI}} + platform, ok := svc.UpstreamPlatformForModel(context.Background(), key, "opaque-model") + require.True(t, ok) + require.Equal(t, PlatformOpenAI, platform) + platform, ok = svc.UpstreamPlatformForModel(context.Background(), key, "grok-4.7") + require.True(t, ok) + require.Equal(t, PlatformGrok, platform) +} + +func TestUpstreamPlatformCatalogMatch_ChannelAliasKeepsAdmission(t *testing.T) { + for _, tc := range []struct { + name string + scope string + allowlist []string + mapped string + restricted bool + want bool + }{ + {name: "mapped model in catalog", mapped: "gpt-5.1", want: true}, + {name: "public allowlist retained", mapped: "gpt-5.1", allowlist: []string{"gpt-other"}}, + {name: "restricted alias", mapped: "gpt-5.1", restricted: true}, + {name: "empty mapping", mapped: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + group := &Group{ID: 2, Platform: PlatformOpenAI} + if tc.allowlist != nil { + group.ModelAllowlist = GroupModelsListConfig{Enabled: true, Models: tc.allowlist} + } + key := &APIKey{GroupID: &group.ID, Group: group, ModelScope: tc.scope} + platform, matched, _ := upstreamPlatformCatalogMatch(context.Background(), key, "gpt-public", []int64{2}, + func(context.Context, int64) *Group { return group }, + func(_ context.Context, groupID *int64, platform string) []string { + require.Equal(t, group.ID, *groupID) + if platform == PlatformOpenAI { + return []string{tc.mapped} + } + return nil + }, + func(_ context.Context, groupID *int64, model string) (ChannelMappingResult, bool) { + require.Equal(t, group.ID, *groupID) + require.Equal(t, "gpt-public", model) + return ChannelMappingResult{Mapped: true, MappedModel: tc.mapped}, tc.restricted + }) + require.Equal(t, tc.want, matched) + if tc.want { + require.Equal(t, PlatformOpenAI, platform) + } else { + require.Empty(t, platform) + } + }) + } +} diff --git a/backend/internal/service/api_key_scope_selection.go b/backend/internal/service/api_key_scope_selection.go deleted file mode 100644 index 0a18e08..0000000 --- a/backend/internal/service/api_key_scope_selection.go +++ /dev/null @@ -1,46 +0,0 @@ -package service - -import "context" - -// Selection must admit the model the executor will send, including compact -// and passthrough semantics. A capability placeholder is checked at execution. -func openAIAccountAllowsModelScope(ctx context.Context, account *Account, requestedModel string, requireCompact bool) bool { - if ModelScopeFromContext(ctx) == ModelScopeAll { - return true - } - if !ValidModelScope(ModelScopeFromContext(ctx)) { - return false - } - if requestedModel == "" { - return true - } - return CheckModelScope(ctx, requestedModel, resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, requireCompact)) == nil -} - -// The native gateway uses ordinary protocol mappings. In particular, an -// Anthropic API-key account applies model_mapping, never compact_model_mapping. -func gatewayAccountAllowsModelScope(ctx context.Context, account *Account, requestedModel string) bool { - if ModelScopeFromContext(ctx) == ModelScopeAll { - return true - } - if account == nil || !ValidModelScope(ModelScopeFromContext(ctx)) { - return false - } - if requestedModel == "" { - return true - } - upstreamModel := requestedModel - switch account.Platform { - case PlatformAnthropic: - if account.IsAPIKeyOrBedrock() || account.Type == AccountTypeServiceAccount { - upstreamModel = account.GetMappedModel(requestedModel) - } - case PlatformAntigravity: - upstreamModel = mapAntigravityModel(account, requestedModel) - case PlatformOpenAI: - upstreamModel = resolveOpenAIAccountUpstreamModelForRequest(account, requestedModel, false) - default: - upstreamModel = account.GetMappedModel(requestedModel) - } - return CheckModelScope(ctx, requestedModel, upstreamModel) == nil -} diff --git a/backend/internal/service/api_key_scope_selection_regression_test.go b/backend/internal/service/api_key_scope_selection_regression_test.go deleted file mode 100644 index a920d82..0000000 --- a/backend/internal/service/api_key_scope_selection_regression_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "testing" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestScopeSelectionCapabilityPlaceholder(t *testing.T) { - for _, scope := range []string{ModelScopeOpenAI, ModelScopeOther, ModelScopeCN, "invalid"} { - t.Run(scope, func(t *testing.T) { - gid := int64(1) - group := &Group{ID: gid, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true} - key := &APIKey{ModelScope: scope, GroupID: &gid, Group: group, User: &User{ID: 1}} - routes := newAPIKeyRouteIterator(context.Background(), key, []int64{gid}, "", - func(context.Context, *APIKey, int64) (*APIKey, error) { return key, nil }, nil, - func(context.Context, *int64) groupModelsCatalog { return groupModelsCatalog{} }, - func(context.Context, *int64, string) (ChannelMappingResult, bool) { - return ChannelMappingResult{}, false - }, nil) - require.Equal(t, scope != "invalid", routes.loadNext()) - if scope == "invalid" { - require.ErrorIs(t, routes.err, ErrModelScopeDenied) - } else { - require.NoError(t, routes.err) - require.Empty(t, routes.candidates[0].model) - } - }) - } -} - -func TestScopeSelectionMixedChoosesSafeMappedAccount(t *testing.T) { - accounts := []Account{ - {ID: 1, Platform: PlatformMixed, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Priority: 1, Credentials: map[string]any{"model_mapping": map[string]any{"qwen3": "gpt-5"}}}, - {ID: 2, Platform: PlatformMixed, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Priority: 2, Credentials: map[string]any{"model_mapping": map[string]any{"qwen3": "qwen3"}}}, - } - svc := &OpenAIGatewayService{cfg: &config.Config{RunMode: config.RunModeSimple}} - ctx := ContextWithModelScope(context.Background(), ModelScopeCN) - selected, _, _ := svc.selectBestAccount(ctx, nil, PlatformMixed, accounts, "qwen3", nil, false, "", false) - require.NotNil(t, selected) - require.Equal(t, int64(2), selected.ID) - scheduler := &defaultOpenAIAccountScheduler{service: svc} - req := OpenAIAccountScheduleRequest{Platform: PlatformMixed, RequestedModel: "qwen3"} - allowed, reason := scheduler.isAccountRequestCompatibleReason(ctx, &accounts[0], req) - require.False(t, allowed) - require.Equal(t, "model_scope_denied", reason) - require.True(t, scheduler.isAccountRequestCompatible(ctx, &accounts[1], req)) - require.Nil(t, svc.recheckSelectedOpenAIAccountFromDBBeforeProfit(ctx, &accounts[0], nil, PlatformMixed, "qwen3", false, "")) - selected, _, _ = svc.selectBestAccount(context.Background(), nil, PlatformMixed, accounts, "qwen3", nil, false, "", false) - require.NotNil(t, selected) - require.Equal(t, int64(1), selected.ID, "legacy all keeps original account priority") -} - -func TestScopeSelectionUsesCompactAndPassthroughMapping(t *testing.T) { - ctx := ContextWithModelScope(context.Background(), ModelScopeOpenAI) - account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, - Extra: map[string]any{"openai_passthrough": true}, - Credentials: map[string]any{ - "model_mapping": map[string]any{"gpt-5": "qwen3"}, - "compact_model_mapping": map[string]any{"gpt-5": "qwen3"}, - }} - require.True(t, openAIAccountAllowsModelScope(ctx, account, "gpt-5", false), "passthrough ignores ordinary model mapping") - require.False(t, openAIAccountAllowsModelScope(ctx, account, "gpt-5", true), "compact passthrough still applies compact mapping") - account.Extra["openai_passthrough"] = false - require.False(t, openAIAccountAllowsModelScope(ctx, account, "gpt-5", false)) - account.Credentials["compact_model_mapping"] = map[string]any{"gpt-5": "gpt-5"} - require.True(t, openAIAccountAllowsModelScope(ctx, account, "gpt-5", true), "exact compact mapping precedes normal mapping") -} - -type scopeNativeSelectionRepo struct { - AccountRepository - accounts []Account -} - -func (r *scopeNativeSelectionRepo) ListSchedulableByPlatforms(context.Context, []string) ([]Account, error) { - return r.accounts, nil -} - -func TestScopeSelectionNativeAnthropicChoosesSafeMappedAccount(t *testing.T) { - accounts := []Account{ - {ID: 1, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Priority: 1, - Credentials: map[string]any{"model_mapping": map[string]any{"claude-sonnet-4-5": "qwen3"}, - "compact_model_mapping": map[string]any{"claude-sonnet-4-5": "claude-sonnet-4-5"}}}, - {ID: 2, Platform: PlatformAnthropic, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Priority: 2, - Credentials: map[string]any{"model_mapping": map[string]any{"claude-sonnet-4-5": "claude-sonnet-4-5"}, - "compact_model_mapping": map[string]any{"claude-sonnet-4-5": "qwen3"}}}, - } - svc := &GatewayService{accountRepo: &scopeNativeSelectionRepo{accounts: accounts}, cfg: &config.Config{RunMode: config.RunModeSimple}} - for _, mode := range []string{"native", "mixed"} { - t.Run(mode, func(t *testing.T) { - selectAccount := func(ctx context.Context) (*Account, error) { - if mode == "mixed" { - return svc.selectAccountWithMixedScheduling(ctx, nil, "", "claude-sonnet-4-5", nil, PlatformAnthropic) - } - return svc.selectAccountForModelWithPlatform(ctx, nil, "", "claude-sonnet-4-5", nil, PlatformAnthropic) - } - selected, err := selectAccount(ContextWithModelScope(context.Background(), ModelScopeAnthropic)) - require.NoError(t, err) - require.NotNil(t, selected) - require.Equal(t, int64(2), selected.ID, "an invalid priority-1 account must not block the legal candidate") - selected, err = selectAccount(context.Background()) - require.NoError(t, err) - require.Equal(t, int64(1), selected.ID, "legacy all preserves existing priority") - }) - } -} - -func TestScopeSelectionNativeUsesOrdinaryProtocolMapping(t *testing.T) { - for _, tc := range []struct { - name, scope, platform, accountType, model, upstream string - passthrough, allowed bool - }{ - {"anthropic API-key mapped across scope", ModelScopeAnthropic, PlatformAnthropic, AccountTypeAPIKey, "claude-sonnet-4-5", "qwen3", false, false}, - {"anthropic OAuth ignores account mapping", ModelScopeAnthropic, PlatformAnthropic, AccountTypeOAuth, "claude-sonnet-4-5", "qwen3", false, true}, - {"anthropic Vertex applies explicit mapping", ModelScopeAnthropic, PlatformAnthropic, AccountTypeServiceAccount, "claude-sonnet-4-5", "qwen3", false, false}, - {"anthropic Bedrock applies ordinary mapping", ModelScopeAnthropic, PlatformAnthropic, AccountTypeBedrock, "claude-sonnet-4-5", "qwen3", false, false}, - {"antigravity applies ordinary mapping", ModelScopeAnthropic, PlatformAntigravity, AccountTypeOAuth, "claude-sonnet-4-5", "qwen3", false, false}, - {"openai passthrough ignores ordinary mapping", ModelScopeOpenAI, PlatformOpenAI, AccountTypeOAuth, "gpt-5", "qwen3", true, true}, - {"openai ordinary mapping is checked", ModelScopeOpenAI, PlatformOpenAI, AccountTypeOAuth, "gpt-5", "qwen3", false, false}, - {"gemini ordinary protocol ignores compact mapping", ModelScopeOther, PlatformGemini, AccountTypeAPIKey, "gemini-2.5-pro", "gemini-2.5-pro", false, true}, - {"mixed ordinary protocol ignores compact mapping", ModelScopeCN, PlatformMixed, AccountTypeAPIKey, "qwen3", "qwen3", false, true}, - {"requested model is checked before safe mapping", ModelScopeAnthropic, PlatformAnthropic, AccountTypeAPIKey, "qwen3", "claude-sonnet-4-5", false, false}, - {"unknown scope fails closed", "invalid", PlatformAnthropic, AccountTypeAPIKey, "claude-sonnet-4-5", "claude-sonnet-4-5", false, false}, - } { - t.Run(tc.name, func(t *testing.T) { - account := &Account{Platform: tc.platform, Type: tc.accountType, Status: StatusActive, Schedulable: true, - Credentials: map[string]any{"model_mapping": map[string]any{tc.model: tc.upstream}, - "compact_model_mapping": map[string]any{tc.model: "gpt-5"}}, - Extra: map[string]any{"openai_passthrough": tc.passthrough}} - ctx := ContextWithModelScope(context.Background(), tc.scope) - require.Equal(t, tc.allowed, (&GatewayService{}).isAccountSchedulableForModelSelection(ctx, account, tc.model)) - }) - } -} - -func TestScopeSelectionLiveActualModelAndDefault(t *testing.T) { - for _, tc := range []struct { - scope, session string - allowed bool - }{ - {ModelScopeOpenAI, `{}`, true}, - {ModelScopeCN, `{}`, false}, - {ModelScopeOpenAI, `{"model":"gpt-live"}`, true}, - {ModelScopeOpenAI, `{"model":"qwen3"}`, false}, - {ModelScopeOpenAI, `{"model":"gpt-live","MODEL":"qwen3"}`, false}, - } { - ctx := ContextWithModelScope(context.Background(), tc.scope) - err := checkLiveCallModelScope(ctx, &LiveCallRequest{Session: json.RawMessage(tc.session)}) - if tc.allowed { - require.NoError(t, err) - } else { - require.ErrorIs(t, err, ErrModelScopeDenied) - } - } - svc := &OpenAIGatewayService{} - require.False(t, svc.shouldFailoverLiveCreateError(nil, fmt.Errorf("wrapped: %w", ErrModelScopeDenied))) -} - -func TestScopeSelectionLocalDenialPreservesAffinityAndScheduling(t *testing.T) { - ctx := withUpstreamAttempts(context.Background()) - cache := &failedAffinityCacheStub{} - account := &Account{ID: 1} - err := fmt.Errorf("wrapped: %w", ErrModelScopeDenied) - invalidateUpstreamAffinity(ctx, cache, nil, []string{"session"}, account, err) - require.Empty(t, cache.invalidated) - state, ok := ctx.Value(upstreamAttemptContextKey{}).(*upstreamAttempts) - require.True(t, ok) - require.Empty(t, state.failed) - // A nil service would panic if the policy error reached any scheduler or - // account health operation; the refusal must return before those hooks. - var svc *OpenAIGatewayService - require.False(t, svc.ReportOpenAIAccountScheduleResult(account, "qwen3", false, nil, err)) - require.False(t, svc.ObserveOpenAIAccountHealthFailure(ctx, account, err)) -} - -func TestScopeSelectionMixedDenialDoesNotStartProtocolFallback(t *testing.T) { - account := &Account{Platform: PlatformMixed, Type: AccountTypeAPIKey, - Credentials: map[string]any{"model_mapping": map[string]any{"qwen3": "gpt-5"}}} - ctx := ContextWithModelScope(context.Background(), ModelScopeCN) - called := false - _, err := (&OpenAIGatewayService{}).forwardWithProtocolFallback(ctx, &gin.Context{}, account, - []byte(`{"model":"qwen3"}`), APIProtocolResponses, "", "", - func(context.Context) (*OpenAIForwardResult, error) { called = true; return nil, nil }) - require.ErrorIs(t, err, ErrModelScopeDenied) - require.False(t, called) -} diff --git a/backend/internal/service/api_key_scope_transport.go b/backend/internal/service/api_key_scope_transport.go deleted file mode 100644 index 742f43c..0000000 --- a/backend/internal/service/api_key_scope_transport.go +++ /dev/null @@ -1,103 +0,0 @@ -package service - -import ( - "bytes" - "io" - "net/http" - "net/url" - "strings" - - "github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel" - "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" - "github.com/tidwall/gjson" -) - -// CheckUpstreamModelScope runs immediately before network/plugin execution, so -// exact/wildcard account mappings, compact mappings and protocol retries cannot -// move a request across provider categories. It never mutates model names. -func CheckUpstreamModelScope(req *http.Request) error { - if req == nil || ModelScopeFromContext(req.Context()) == ModelScopeAll { - return nil - } - if !ValidModelScope(ModelScopeFromContext(req.Context())) { - return ErrModelScopeDenied - } - var models []string - if req.URL != nil { - path := req.URL.Path - if i := strings.LastIndex(path, "/models/"); i >= 0 { - model := path[i+len("/models/"):] - if action := strings.IndexByte(model, ':'); action >= 0 { - model = model[:action] - } - models = append(models, model) - } - // Bedrock stores the final invocation model in the URL, not JSON. - if i := strings.Index(path, "/model/"); i >= 0 { - model := strings.SplitN(path[i+len("/model/"):], "/", 2)[0] - if decoded, err := url.PathUnescape(model); err == nil { - model = decoded - } - models = append(models, model) - } - if model := req.URL.Query().Get("model"); model != "" { - models = append(models, model) - } - } - if req.Body != nil && req.Body != http.NoBody { - var body []byte - var err error - if req.GetBody != nil { - copyBody, copyErr := req.GetBody() - if copyErr != nil { - return copyErr - } - body, err = io.ReadAll(copyBody) - _ = copyBody.Close() - } else { - body, err = io.ReadAll(req.Body) - _ = req.Body.Close() - req.Body = io.NopCloser(bytes.NewReader(body)) - } - if err != nil { - return err - } - models = append(models, requestmodel.FromBodyCandidates("", req.Header.Get("Content-Type"), body)...) - models = append(models, requestmodel.FromBodyCandidates("/v1/live", req.Header.Get("Content-Type"), body)...) - // Google v1internal envelopes put generation parameters under request. - if nested := gjson.GetBytes(body, "request"); nested.IsObject() { - models = append(models, requestmodel.FromBodyCandidates("", "application/json", []byte(nested.Raw))...) - } - // Grok's internal generation endpoint names this field modelName. - if model := gjson.GetBytes(body, "modelName").String(); model != "" { - models = append(models, model) - } - } - return CheckModelScope(req.Context(), models...) -} - -type modelScopeUpstream struct{ HTTPUpstream } - -func withModelScopeUpstream(upstream HTTPUpstream) HTTPUpstream { - if upstream == nil { - return nil - } - if _, ok := upstream.(*modelScopeUpstream); ok { - return upstream - } - return &modelScopeUpstream{HTTPUpstream: upstream} -} - -func (u *modelScopeUpstream) Do(req *http.Request, proxy string, accountID int64, concurrency int) (*http.Response, error) { - if err := CheckUpstreamModelScope(req); err != nil { - return nil, err - } - return u.HTTPUpstream.Do(req, proxy, accountID, concurrency) -} - -func (u *modelScopeUpstream) DoWithTLS(req *http.Request, proxy string, accountID int64, concurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) { - if err := CheckUpstreamModelScope(req); err != nil { - return nil, err - } - return u.HTTPUpstream.DoWithTLS(req, proxy, accountID, concurrency, profile) -} diff --git a/backend/internal/service/api_key_scope_ws_regression_test.go b/backend/internal/service/api_key_scope_ws_regression_test.go deleted file mode 100644 index 72e3dcb..0000000 --- a/backend/internal/service/api_key_scope_ws_regression_test.go +++ /dev/null @@ -1,84 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "testing" - "time" - - coderws "github.com/coder/websocket" - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -func TestModelScopeWSPassthroughSessionInheritanceAndRefusal(t *testing.T) { - gin.SetMode(gin.TestMode) - ctx, cancel := context.WithCancel(ContextWithModelScope(context.Background(), ModelScopeOpenAI)) - defer cancel() - upstream := newStagedPassthroughConn() - svc := newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream) - server, serverErr := startPassthroughLifecycleServer(t, ctx, svc, passthroughLifecycleAccount()) - defer server.Close() - client := dialPassthroughLifecycleClient(t, server) - defer client.CloseNow() - require.Equal(t, "gpt-5.1", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "model").String()) - upstream.Send(`{"type":"response.completed","response":{"id":"first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) - _, err := readPassthroughLifecycleFrame(t, client, time.Second) - require.NoError(t, err) - write := func(payload string) { - t.Helper() - writeCtx, stop := context.WithTimeout(ctx, time.Second) - defer stop() - require.NoError(t, client.Write(writeCtx, coderws.MessageText, []byte(payload))) - } - write(`{"type":"session.update","session":{"model":"gpt-5.4"}}`) - require.Equal(t, "gpt-5.4", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "session.model").String()) - write(`{"type":"response.create","stream":false}`) - require.Equal(t, "gpt-5.4", gjson.GetBytes(requirePassthroughUpstreamWrite(t, upstream, time.Second), "model").String(), "omitted model inherits the allowed session") - upstream.Send(`{"type":"response.completed","response":{"id":"second","model":"gpt-5.4","usage":{"input_tokens":1,"output_tokens":1}}}`) - _, err = readPassthroughLifecycleFrame(t, client, time.Second) - require.NoError(t, err) - write(`{"type":"session.update","session":{"model":"qwen3"}}`) - // Consume the close handshake before waiting for the relay to finish. - _, err = readPassthroughLifecycleFrame(t, client, 3*time.Second) - var closeErr coderws.CloseError - require.ErrorAs(t, err, &closeErr) - require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code) - select { - case err = <-serverErr: - require.ErrorIs(t, err, ErrModelScopeDenied) - case <-time.After(3 * time.Second): - t.Fatal("cross-scope session update was not rejected") - } - select { - case payload := <-upstream.writes: - t.Fatalf("cross-scope frame reached upstream: %s", payload) - default: - } -} - -func TestModelScopeWSPassthroughFirstFinalMappingRefused(t *testing.T) { - ctx, cancel := context.WithCancel(ContextWithModelScope(context.Background(), ModelScopeOpenAI)) - defer cancel() - upstream := newStagedPassthroughConn() - svc := newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream) - server, serverErr := startPassthroughLifecycleServerWithHooks(t, ctx, svc, passthroughLifecycleAccount(), func(*gin.Context) *OpenAIWSIngressHooks { - return &OpenAIWSIngressHooks{MapRequestModel: func(int, string) (string, error) { return "qwen3", nil }} - }) - defer server.Close() - client := dialPassthroughLifecycleClient(t, server) - defer client.CloseNow() - select { - case err := <-serverErr: - require.ErrorIs(t, err, ErrModelScopeDenied) - case <-time.After(3 * time.Second): - t.Fatal("first-frame cross-scope mapping was not rejected") - } - select { - case payload := <-upstream.writes: - t.Fatalf("cross-scope first frame reached upstream: %s", payload) - default: - } -} diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index e144060..a41fefc 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -222,7 +222,6 @@ type APIKeyAuthCacheInvalidator interface { // CreateAPIKeyRequest 创建API Key请求 type CreateAPIKeyRequest struct { - ModelScope string `json:"model_scope"` Name string `json:"name"` GroupID *int64 `json:"group_id"` GroupIDs []int64 `json:"group_ids"` @@ -246,7 +245,6 @@ type CreateAPIKeyRequest struct { // UpdateAPIKeyRequest 更新API Key请求 type UpdateAPIKeyRequest struct { - ModelScope *string `json:"model_scope"` Name *string `json:"name"` GroupID *int64 `json:"group_id"` GroupIDs *[]int64 `json:"group_ids"` @@ -279,9 +277,6 @@ func validateAPIKeyLimit(v float64) error { } func validateCreateAPIKeyRequest(req CreateAPIKeyRequest) error { - if !ValidModelScope(req.ModelScope) { - return ErrInvalidModelScope - } for _, v := range []float64{req.Quota, req.RateLimit5h, req.RateLimit1d, req.RateLimit7d} { if err := validateAPIKeyLimit(v); err != nil { return err @@ -294,9 +289,6 @@ func validateCreateAPIKeyRequest(req CreateAPIKeyRequest) error { } func validateUpdateAPIKeyRequest(req UpdateAPIKeyRequest) error { - if req.ModelScope != nil && !ValidModelScope(*req.ModelScope) { - return ErrInvalidModelScope - } for _, v := range []*float64{req.Quota, req.RateLimit5h, req.RateLimit1d, req.RateLimit7d} { if v != nil { if err := validateAPIKeyLimit(*v); err != nil { @@ -314,9 +306,6 @@ type RateLimitCacheInvalidator interface { } type APIKeyService struct { - groupCatalogue interface { - SupportedKeyProviders(context.Context, *Group) []string - } apiKeyRepo APIKeyRepository userRepo UserRepository groupRepo GroupRepository @@ -407,12 +396,6 @@ func (s *APIKeyService) SetConcurrencyService(concurrencyService *ConcurrencySer s.concurrencyService = concurrencyService } -func (s *APIKeyService) SetGroupCatalogue(catalogue interface { - SupportedKeyProviders(context.Context, *Group) []string -}) { - s.groupCatalogue = catalogue -} - // SetLastUsedScheduler routes authentication activity timestamps to the // process-wide deferred batcher. It is intentionally a narrow callback so the // API-key service does not depend on the DeferredService concrete type. @@ -616,7 +599,7 @@ func (s *APIKeyService) Create(ctx context.Context, userID int64, req CreateAPIK // 创建API Key记录 apiKey := &APIKey{ - ModelScope: NormalizeModelScope(req.ModelScope), + ModelScope: ModelScopeAll, UserID: userID, Key: key, Name: html.EscapeString(req.Name), @@ -991,10 +974,6 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req apiKey.AnthropicCacheTTLMode = NormalizeAnthropicCacheTTLMode(*req.AnthropicCacheTTLMode) fields.AnthropicCacheTTLMode = true } - if req.ModelScope != nil { - apiKey.ModelScope = NormalizeModelScope(*req.ModelScope) - fields.ModelScope = true - } resetRateLimit := req.ResetRateLimitUsage != nil && *req.ResetRateLimitUsage if resetRateLimit { apiKey.Usage5h = 0 @@ -1176,9 +1155,6 @@ func (s *APIKeyService) GetAvailableGroups(ctx context.Context, userID int64) ([ availableGroups := make([]Group, 0) for _, group := range allGroups { if s.canUserBindGroupInternal(user, &group, subscribedGroupIDs) { - if s.groupCatalogue != nil { - group.SupportedKeyProviders = s.groupCatalogue.SupportedKeyProviders(ctx, &group) - } availableGroups = append(availableGroups, group) } } diff --git a/backend/internal/service/billing_inflight_reservation.go b/backend/internal/service/billing_inflight_reservation.go index 31edaa0..c883051 100644 --- a/backend/internal/service/billing_inflight_reservation.go +++ b/backend/internal/service/billing_inflight_reservation.go @@ -432,13 +432,13 @@ func inflightBillingModelCandidates(ctx context.Context, deps inflightEstimateDe return primary, fallbacks, upstreamInput } -func (d inflightEstimateDeps) rates(ctx context.Context, apiKey *APIKey, model string) (text, image float64) { +func (d inflightEstimateDeps) rates(ctx context.Context, apiKey *APIKey) (text, image float64) { rate := 1.0 if d.cfg != nil && d.cfg.Default.RateMultiplier > 0 { rate = d.cfg.Default.RateMultiplier } if apiKey != nil && apiKey.GroupID != nil && apiKey.Group != nil { - rate = apiKey.Group.RateMultiplierForModel(model) + rate = apiKey.Group.RateMultiplier if d.userGroupRate != nil && apiKey.User != nil { rate = d.userGroupRate(ctx, apiKey.User.ID, *apiKey.GroupID, rate) } @@ -598,72 +598,39 @@ func (d inflightEstimateDeps) estimate(ctx context.Context, apiKey *APIKey, req // 非计量请求(媒体状态查询、custom-voices 等):无需预留,也不算「无法定价」。 return 0, true } - textRate, imageRate := d.rates(ctx, apiKey, req.Model) - mixed := apiKey.Group != nil && apiKey.Group.Platform == PlatformMixed - if textRate <= 0 && imageRate <= 0 && !mixed { + textRate, imageRate := d.rates(ctx, apiKey) + if textRate <= 0 && imageRate <= 0 { // 免费分组:不计费,也无需预留。 return 0, true } primary, fallbacks, upstreamInput := inflightBillingModelCandidates(ctx, d, apiKey, req.Model) - billingSource := "" - fixedChannelIdentity := false - if mixed && d.resolveMapping != nil && apiKey.GroupID != nil { - mapping := d.resolveMapping(ctx, *apiKey.GroupID, req.Model) - billingSource = mapping.BillingModelSource - fixedChannelIdentity = billingSource == BillingModelSourceChannelMapped && mapping.MappedModel != "" && mapping.MappedModel != req.Model - if fixedChannelIdentity { - primary = []string{mapping.MappedModel} - } - } - upstream := billingSource == "" || billingSource == BillingModelSourceUpstream - allCandidatesFree := true - bestOf := func(models []string) (float64, bool) { + bestOf := func(models []string) float64 { best := 0.0 - priced := false for _, m := range models { - candidateText, candidateImage := textRate, imageRate - if mixed { - m = mixedBillingPricingIdentity(ctx, d.billing, d.resolver, apiKey.GroupID, apiKey.Group, m, upstream) - candidateText, candidateImage = d.rates(ctx, apiKey, m) - } - allCandidatesFree = allCandidatesFree && candidateText <= 0 && candidateImage <= 0 - c, candidatePriced := d.estimateOne(ctx, apiKey, m, req, candidateText, candidateImage) - priced = priced || candidatePriced + c, _ := d.estimateOne(ctx, apiKey, m, req, textRate, imageRate) if c > best { best = c } } - return best, priced - } - best, priced := bestOf(primary) - // A priced explicit identity wins, even when its vendor is free. Do not - // reserve account targets that neither usage billing path will charge. - if mixed && (billingSource == BillingModelSourceRequested || fixedChannelIdentity) && priced { - return best, true + return best } + best := bestOf(primary) // composite 分组:计费侧除非别名有显式渠道价,否则按实际转发的具体模型计费; // 别名本身可能命中家族模糊价(低估),因此与候选具体模型一起取最高。 composite := apiKey.Group != nil && apiKey.Group.Platform == PlatformComposite - if best <= 0 || composite || mixed { + if best <= 0 || composite { // 与 billableModelWithFallback 同口径:首选模型无价时回退到实际转发模型。 - c, fallbackPriced := bestOf(fallbacks) - priced = priced || fallbackPriced + c := bestOf(fallbacks) if c > best { best = c } - // Mixed rates may differ after account mapping, even if the public alias - // is priced. Read those candidates from the scheduler snapshot too. - if (best <= 0 || composite || mixed) && d.accountMappedModels != nil { - c, mappedPriced := bestOf(d.accountMappedModels(ctx, apiKey, upstreamInput)) - priced = priced || mappedPriced + if (best <= 0 || composite) && d.accountMappedModels != nil { + c := bestOf(d.accountMappedModels(ctx, apiKey, upstreamInput)) if c > best { best = c } } } - if mixed && (priced || allCandidatesFree) { - return best, true - } if best <= 0 { logInflightUnpriced(req.Model, apiKey.GroupID) return 0, false diff --git a/backend/internal/service/channel_service_test.go b/backend/internal/service/channel_service_test.go index ad565d8..c87f4c5 100644 --- a/backend/internal/service/channel_service_test.go +++ b/backend/internal/service/channel_service_test.go @@ -2183,8 +2183,7 @@ func TestMatchingPlatforms(t *testing.T) { {"anthropic returns itself", PlatformAnthropic, []string{PlatformAnthropic}}, {"gemini returns itself", PlatformGemini, []string{PlatformGemini}}, {"openai returns itself", PlatformOpenAI, []string{PlatformOpenAI}}, - {"mixed returns itself only", PlatformMixed, []string{PlatformMixed}}, - {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformMixed, PlatformCommandCode, PlatformCline, PlatformTypeSafe}}, + {"composite returns concrete platforms", PlatformComposite, []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformCommandCode, PlatformCline, PlatformTypeSafe}}, } for _, tt := range tests { diff --git a/backend/internal/service/claude55_bridge_regression_test.go b/backend/internal/service/claude55_bridge_regression_test.go index 5d129ff..b6736ec 100644 --- a/backend/internal/service/claude55_bridge_regression_test.go +++ b/backend/internal/service/claude55_bridge_regression_test.go @@ -48,8 +48,7 @@ func TestClaude55BridgesBillFinalMaxEffort(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} if native { - account.Platform, group.Platform = PlatformMixed, PlatformMixed - group.MixedProviderRateMultipliers = map[string]float64{"anthropic": 0.15} + account.Platform, group.Platform = PlatformCommandCode, PlatformCommandCode svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) svc.cfg, svc.httpUpstream = rawChatCompletionsTestConfig(), upstream svc.resolver = NewModelPricingResolver(nil, svc.billingService) @@ -126,7 +125,7 @@ func TestClaude55BridgeInvalidParametersAre400BeforeTransport(t *testing.T) { } func TestHaiku55EnabledRejectedBeforeMessagesAndCountTokensTransport(t *testing.T) { - for _, endpoint := range []string{"messages", "count_tokens", "mixed-messages"} { + for _, endpoint := range []string{"messages", "count_tokens", "native-messages"} { t.Run(endpoint, func(t *testing.T) { rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) @@ -139,8 +138,8 @@ func TestHaiku55EnabledRejectedBeforeMessagesAndCountTokensTransport(t *testing. switch endpoint { case "messages": _, err = (&GatewayService{}).Forward(context.Background(), c, account, parsed) - case "mixed-messages": - account.Platform = PlatformMixed + case "native-messages": + account.Platform = PlatformCommandCode _, err = (&OpenAIGatewayService{}).forwardAnthropicViaNativeAnthropicEndpoint(context.Background(), c, account, body, "") default: err = (&GatewayService{}).ForwardCountTokens(context.Background(), c, account, parsed) diff --git a/backend/internal/service/composite_platform_test.go b/backend/internal/service/composite_platform_test.go index b03b503..7240454 100644 --- a/backend/internal/service/composite_platform_test.go +++ b/backend/internal/service/composite_platform_test.go @@ -245,7 +245,7 @@ func TestCompositeGroupSchedulerHasAllCanonicalPlatformBuckets(t *testing.T) { platforms = append(platforms, platform) } require.ElementsMatch(t, - []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformMixed, PlatformCommandCode, PlatformCline, PlatformTypeSafe}, + []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformCline, PlatformCommandCode, PlatformTypeSafe}, platforms, ) } diff --git a/backend/internal/service/composite_route_catalog_test.go b/backend/internal/service/composite_route_catalog_test.go index 7743c1e..e3ac50e 100644 --- a/backend/internal/service/composite_route_catalog_test.go +++ b/backend/internal/service/composite_route_catalog_test.go @@ -31,23 +31,6 @@ func TestCompositeRouteCatalogExactAliases(t *testing.T) { require.ElementsMatch(t, []string{"public-any", "public-responses"}, responses) } -func TestCompositeRouteCatalogScopeUsesSelectedMappedTarget(t *testing.T) { - r := NewCompositeRouteResolver(compositeRouteRepoStub{routes: []CompositeModelRoute{ - {ID: 1, GroupID: 7, PublicModel: "gpt-safe", UpstreamModel: "gpt-5.4", MatchType: CompositeRouteMatchExact, Enabled: true}, - {ID: 2, GroupID: 7, PublicModel: "gpt-cross-family", UpstreamModel: "claude-sonnet-4-6", MatchType: CompositeRouteMatchExact, Enabled: true}, - // Lower-priority same-name route cannot make a forbidden winner visible. - {ID: 3, GroupID: 7, PublicModel: "gpt-cross-family", UpstreamModel: "gpt-5.4", MatchType: CompositeRouteMatchExact, Priority: 100, Enabled: true}, - {ID: 4, GroupID: 7, PublicModel: "deepseek-public", UpstreamModel: "deepseek-v3.2", MatchType: CompositeRouteMatchExact, Enabled: true}, - }}) - ctx := ContextWithModelScope(context.Background(), ModelScopeOpenAI) - got, err := r.ListExactPublicModels(ctx, 7, CompositeRouteEndpointResponses) - require.NoError(t, err) - require.Equal(t, []string{"gpt-safe"}, got) - got, err = r.ListExactPublicModels(ContextWithModelScope(context.Background(), ModelScopeCN), 7, "") - require.NoError(t, err) - require.Equal(t, []string{"deepseek-public"}, got) -} - type compositeCatalogErrorRepo struct{ CompositeModelRouteRepository } func (compositeCatalogErrorRepo) ListByGroup(context.Context, int64, bool) ([]CompositeModelRoute, error) { @@ -68,29 +51,3 @@ func TestCompositeRouteCatalogErrorAndCacheInvalidation(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{"after"}, got) } - -func TestCompositeRouteCatalogScopeDoesNotBorrowAnotherPlatformAccount(t *testing.T) { - for _, endpoint := range []string{"", CompositeRouteEndpointResponses} { - for _, safeTarget := range []bool{false, true} { - mapped := "gpt-5.4" - if safeTarget { - mapped = "deepseek-v4" - } - repo := &modelScopeAccountRepo{accounts: []Account{ - {Platform: PlatformDeepseek, Credentials: map[string]any{"model_mapping": map[string]any{"deepseek-target": mapped}}}, - {Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Extra: map[string]any{"openai_passthrough": true}}, - }} - svc := &GatewayService{accountRepo: repo, compositeResolver: NewCompositeRouteResolver(compositeRouteRepoStub{routes: []CompositeModelRoute{{ - GroupID: 7, PublicModel: "deepseek-public", UpstreamModel: "deepseek-target", TargetPlatform: PlatformDeepseek, - MatchType: CompositeRouteMatchExact, Enabled: true, - }}})} - groupID := int64(7) - got := svc.FilterModelsForKeyScope(context.Background(), &groupID, ModelScopeCN, []string{"deepseek-public"}, endpoint) - if safeTarget { - require.Equal(t, []string{"deepseek-public"}, got) - } else { - require.Empty(t, got) - } - } - } -} diff --git a/backend/internal/service/composite_route_resolver.go b/backend/internal/service/composite_route_resolver.go index 77045e8..eae2782 100644 --- a/backend/internal/service/composite_route_resolver.go +++ b/backend/internal/service/composite_route_resolver.go @@ -92,13 +92,8 @@ func (r *CompositeRouteResolver) ListExactPublicModels(ctx context.Context, grou return nil, err } models := make([]string, 0, len(targets)) - for model, candidates := range targets { - for _, target := range candidates { - if CheckModelScope(ctx, model, target.model) == nil { - models = append(models, model) - break - } - } + for model := range targets { + models = append(models, model) } sort.Strings(models) return models, nil diff --git a/backend/internal/service/cross_protocol_conversion.go b/backend/internal/service/cross_protocol_conversion.go index 4985d6f..ecb0c84 100644 --- a/backend/internal/service/cross_protocol_conversion.go +++ b/backend/internal/service/cross_protocol_conversion.go @@ -42,7 +42,7 @@ func CrossProtocolConversionAllowed(apiKey *APIKey) bool { if apiKey == nil || apiKey.Group == nil { return true } - return apiKey.Group.Platform == PlatformMixed || apiKey.Group.CrossProtocolConversionEnabled + return apiKey.Group.CrossProtocolConversionEnabled } // crossProtocolConversionAllowedFromContext 是 CrossProtocolConversionAllowed diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 587daf1..b03f096 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -44,19 +44,18 @@ const ( const ( PlatformAnthropic = domain.PlatformAnthropic PlatformOpenAI = domain.PlatformOpenAI - PlatformMixed = domain.PlatformMixed - PlatformCommandCode = domain.PlatformCommandCode - PlatformCline = domain.PlatformCline - PlatformTypeSafe = domain.PlatformTypeSafe PlatformGemini = domain.PlatformGemini PlatformAntigravity = domain.PlatformAntigravity PlatformGrok = domain.PlatformGrok // 国产 OpenAI 兼容供应商(与 grok 一样经 OpenAI 网关转发)。 - PlatformKimi = domain.PlatformKimi - PlatformZhipu = domain.PlatformZhipu - PlatformDeepseek = domain.PlatformDeepseek - PlatformMiniMax = domain.PlatformMiniMax - PlatformComposite = domain.PlatformComposite + PlatformKimi = domain.PlatformKimi + PlatformZhipu = domain.PlatformZhipu + PlatformDeepseek = domain.PlatformDeepseek + PlatformMiniMax = domain.PlatformMiniMax + PlatformComposite = domain.PlatformComposite + PlatformCommandCode = domain.PlatformCommandCode + PlatformCline = domain.PlatformCline + PlatformTypeSafe = domain.PlatformTypeSafe // PlatformKiro is retained for unsupported-platform threshold tests and legacy // account rows. Scheduling-threshold evaluation never pauses kiro accounts. PlatformKiro = "kiro" diff --git a/backend/internal/service/gateway_balance_preauthorization.go b/backend/internal/service/gateway_balance_preauthorization.go index 3f8f7d9..bc8a733 100644 --- a/backend/internal/service/gateway_balance_preauthorization.go +++ b/backend/internal/service/gateway_balance_preauthorization.go @@ -60,7 +60,7 @@ func (s *GatewayService) BalancePreauthorizationCostInput( multiplier = s.cfg.Default.RateMultiplier } if s != nil && apiKey != nil && apiKey.GroupID != nil && apiKey.Group != nil { - multiplier = s.ResolveUserGroupRateMultiplier(ctx, balancePreauthorizationAPIKeyUserID(apiKey), *apiKey.GroupID, apiKey.Group.RateMultiplierForModel(model)) + multiplier = s.ResolveUserGroupRateMultiplier(ctx, balancePreauthorizationAPIKeyUserID(apiKey), *apiKey.GroupID, apiKey.Group.RateMultiplier) } multiplier, _ = computePeakAwareMultipliers(apiKey, multiplier, pricingAt) return CostInput{ @@ -90,7 +90,7 @@ func (s *OpenAIGatewayService) BalancePreauthorizationCostInput( multiplier = s.cfg.Default.RateMultiplier } if s != nil && apiKey != nil && apiKey.GroupID != nil && apiKey.Group != nil { - multiplier = s.ResolveUserGroupRateMultiplier(ctx, balancePreauthorizationAPIKeyUserID(apiKey), *apiKey.GroupID, apiKey.Group.RateMultiplierForModel(model)) + multiplier = s.ResolveUserGroupRateMultiplier(ctx, balancePreauthorizationAPIKeyUserID(apiKey), *apiKey.GroupID, apiKey.Group.RateMultiplier) } multiplier, _ = computePeakAwareMultipliers(apiKey, multiplier, pricingAt) return CostInput{ diff --git a/backend/internal/service/gateway_models_catalog.go b/backend/internal/service/gateway_models_catalog.go index 6fec1d0..5a54780 100644 --- a/backend/internal/service/gateway_models_catalog.go +++ b/backend/internal/service/gateway_models_catalog.go @@ -22,15 +22,6 @@ func (s *GatewayService) GetCompositeRouteModels(ctx context.Context, groupID *i type groupModelsCatalog struct { byPlatform map[string][]string platforms map[string]struct{} - // Private mapping snapshots preserve exact/longest-wildcard resolution. - // They contain no account credentials and never synthesize model identities. - scopeMappings []modelScopeMapping -} - -type modelScopeMapping struct { - platform string - mapping map[string]string - passthrough bool } func modelsListCatalogCacheKey(groupID *int64) string { @@ -162,11 +153,6 @@ func loadGroupModelsCatalogFromStore(ctx context.Context, repo AccountRepository addPlatforms := func(accounts []Account) { for i := range accounts { - cat.scopeMappings = append(cat.scopeMappings, modelScopeMapping{ - platform: accounts[i].Platform, - mapping: accounts[i].GetModelMapping(), - passthrough: accounts[i].IsOpenAIPassthroughEnabled(), - }) if platform := strings.TrimSpace(accounts[i].Platform); platform != "" { cat.platforms[platform] = struct{}{} } @@ -199,229 +185,6 @@ func loadGroupModelsCatalogFromStore(ctx context.Context, repo AccountRepository return cat } -// FilterModelsForKeyScope applies the same public and target boundaries as -// forwarding. Opaque aliases that cannot establish the selected family remain -// unavailable for scoped keys. Legacy all keys preserve their original list. -func (s *GatewayService) FilterModelsForKeyScope(ctx context.Context, groupID *int64, scope string, models []string, compositeEndpoint ...string) []string { - if NormalizeModelScope(scope) == ModelScopeAll { - return cloneStringSlice(models) - } - cat := s.ensureGroupModelsCatalog(ctx, groupID) - channelModel := func(model string) string { - mapped, _ := s.ResolveChannelMappingAndRestrict(ctx, groupID, model) - return mapped.MappedModel - } - if len(compositeEndpoint) == 0 || groupID == nil || s.compositeResolver == nil { - return filterModelsForKeyScopeMapped(cat, scope, models, channelModel) - } - targets, err := s.compositeResolver.exactPublicModelTargets(ctx, *groupID, compositeEndpoint[0]) - if err != nil { - // Preserve the account-only catalog when the route store is unavailable. - return filterModelsForKeyScopeMapped(cat, scope, models, channelModel) - } - filtered := make([]string, 0, len(models)) - for _, model := range models { - if !ModelScopeAllows(scope, model) { - continue - } - candidates, routed := targets[model] - if !routed { - if len(filterModelsForKeyScopeMapped(cat, scope, []string{model}, channelModel)) > 0 { - filtered = append(filtered, model) - } - continue - } - // A route alias is authorized against its winning target before the - // channel/account mapping boundary, just like actual forwarding. - for _, target := range candidates { - targetCatalog := cat - if target.platform != "" { - targetCatalog.scopeMappings = nil - for _, account := range cat.scopeMappings { - if account.platform == target.platform { - targetCatalog.scopeMappings = append(targetCatalog.scopeMappings, account) - } - } - // No account of the routed platform cannot establish scope. Do - // not borrow another platform's passthrough/mapping capability. - if len(targetCatalog.scopeMappings) == 0 { - continue - } - } - if len(filterModelsForKeyScopeMapped(targetCatalog, scope, []string{target.model}, channelModel)) > 0 { - filtered = append(filtered, model) - break - } - } - } - return filtered -} - -func (s *OpenAIGatewayService) FilterModelsForKeyScope(ctx context.Context, groupID *int64, scope string, models []string) []string { - if NormalizeModelScope(scope) == ModelScopeAll { - return cloneStringSlice(models) - } - return filterModelsForKeyScopeMapped(s.routeModelsCatalog(ctx, groupID), scope, models, func(model string) string { - mapped, _ := s.ResolveChannelMappingAndRestrict(ctx, groupID, model) - return mapped.MappedModel - }) -} - -func filterModelsForKeyScope(cat groupModelsCatalog, scope string, models []string) []string { - return filterModelsForKeyScopeMapped(cat, scope, models, nil) -} - -func filterModelsForKeyScopeMapped(cat groupModelsCatalog, scope string, models []string, channelModel func(string) string) []string { - if NormalizeModelScope(scope) == ModelScopeAll { - return cloneStringSlice(models) - } - result := make([]string, 0, len(models)) - for _, model := range models { - if !ModelScopeAllows(scope, model) { - continue - } - allowed := len(cat.scopeMappings) == 0 - routedModel := strings.TrimPrefix(model, "models/") - if channelModel != nil { - routedModel = channelModel(routedModel) - } - if !ModelScopeAllows(scope, routedModel) { - continue - } - for _, account := range cat.scopeMappings { - if account.passthrough { - allowed = true - break - } - target, matched := resolveRequestedModelInMapping(account.mapping, routedModel) - if !matched { - normalized := normalizeRequestedModelForLookup(account.platform, routedModel) - target, matched = resolveRequestedModelInMapping(account.mapping, normalized) - } - if !matched { - if len(account.mapping) > 0 { - continue - } - target = routedModel - } - if ModelScopeAllows(scope, target) { - allowed = true - break - } - } - if allowed { - result = append(result, model) - } - } - return result -} - -// Pinned discovery may use an account outside the scheduler's group snapshot. -func FilterModelsForAccountScope(scope string, models []string, account *Account) []string { - if account == nil || NormalizeModelScope(scope) == ModelScopeAll { - return FilterModelScope(scope, models) - } - return filterModelsForKeyScope(groupModelsCatalog{scopeMappings: []modelScopeMapping{{ - platform: account.Platform, mapping: account.GetModelMapping(), passthrough: account.IsOpenAIPassthroughEnabled(), - }}}, scope, models) -} - -func (s *GatewayService) SupportedKeyProviders(ctx context.Context, group *Group) []string { - if group == nil { - return []string{} - } - switch group.Platform { - case PlatformOpenAI: - return []string{ModelScopeOpenAI} - case PlatformAnthropic: - return []string{ModelScopeAnthropic} - case PlatformMixed: - // Mixed deliberately has no synthetic fallback: only its configured - // public mapping keys count. Other platform selectors stay unchanged. - default: - if IsCNProvider(group.Platform) { - return []string{ModelScopeCN} - } - return []string{ModelScopeOther} - } - cat := s.ensureGroupModelsCatalog(ctx, &group.ID) - models := cat.unionModels() - if group.ModelAllowlistEnabled() { - models = group.ModelAllowlist.FilterForListing(models) - } - providers := make([]string, 0, 4) - vendors := make(map[string]bool) - for _, scope := range []string{ModelScopeOpenAI, ModelScopeAnthropic, ModelScopeCN, ModelScopeOther} { - scopedModels := filterModelsForKeyScopeMapped(cat, scope, models, func(model string) string { - mapped, _ := s.ResolveChannelMappingAndRestrict(ctx, &group.ID, model) - return mapped.MappedModel - }) - if len(scopedModels) > 0 { - providers = append(providers, scope) - } - } - // Legacy all-scope keys can use cross-category aliases too. Their priced - // vendors are not necessarily the union of the four restricted scopes. - for _, model := range models { - for _, target := range s.mixedCatalogueBillingModels(ctx, group, model, cat) { - vendors[ModelVendor(target)] = true - } - } - // This request-local authorized projection shares the catalogue load above. - group.SupportedModelVendors = []string{} - for _, vendor := range mixedModelVendors { - if vendors[vendor] { - group.SupportedModelVendors = append(group.SupportedModelVendors, vendor) - } - } - return providers -} - -// Return every possible billing identity for a public alias. Different -// accounts may route the same alias to different vendors; never invent one. -func (s *GatewayService) mixedCatalogueBillingModels(ctx context.Context, group *Group, model string, cat groupModelsCatalog) []string { - groupID := &group.ID - mapped, _ := s.ResolveChannelMappingAndRestrict(ctx, groupID, model) - primary := "" - switch mapped.BillingModelSource { - case BillingModelSourceRequested: - primary = model - case BillingModelSourceChannelMapped: - if mapped.MappedModel != "" && mapped.MappedModel != model { - primary = mapped.MappedModel - } - } - // Explicit sources only fix the billing identity when it has a price. - // Unpriced aliases follow the existing concrete-model fallback instead. - if primary != "" && (s.billingService == nil && s.resolver == nil || mixedBillingModelPriced(ctx, s.billingService, s.resolver, groupID, group, primary)) { - return []string{primary} - } - upstream := mapped.BillingModelSource == "" || mapped.BillingModelSource == BillingModelSourceUpstream - seen := make(map[string]bool) - var targets []string - for _, account := range cat.scopeMappings { - target, matched := resolveRequestedModelInMapping(account.mapping, mapped.MappedModel) - if !matched { - target, matched = resolveRequestedModelInMapping(account.mapping, normalizeRequestedModelForLookup(account.platform, mapped.MappedModel)) - } - if !matched && (len(account.mapping) == 0 || account.passthrough) { - target, matched = mapped.MappedModel, true - } - if matched { - target = mixedBillingPricingIdentity(ctx, s.billingService, s.resolver, groupID, group, target, upstream) - } - if matched && target != "" && !strings.Contains(target, "*") && !seen[target] { - seen[target] = true - targets = append(targets, target) - } - } - if len(targets) == 0 && primary != "" { - targets = append(targets, primary) - } - sort.Strings(targets) - return targets -} - func modelsFromSchedulableAccounts(accounts []Account, platform string) []string { if len(accounts) == 0 { return nil diff --git a/backend/internal/service/gateway_models_display.go b/backend/internal/service/gateway_models_display.go index 8e60d28..d1abb98 100644 --- a/backend/internal/service/gateway_models_display.go +++ b/backend/internal/service/gateway_models_display.go @@ -12,13 +12,6 @@ type plazaGatewayModelCatalog struct { gateway *GatewayService } -func (c plazaGatewayModelCatalog) MixedBillingModels(ctx context.Context, group *Group, model string) []string { - if c.gateway == nil { - return nil - } - return c.gateway.mixedCatalogueBillingModels(ctx, group, model, c.gateway.ensureGroupModelsCatalog(ctx, &group.ID)) -} - func (c plazaGatewayModelCatalog) GetAvailableModels(ctx context.Context, groupID *int64, platform string) []string { if c.gateway == nil { return nil diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index 4c8b115..484346b 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -1259,9 +1259,6 @@ func (s *GatewayService) isAccountSchedulableForModelSelection(ctx context.Conte if account == nil { return false } - if !gatewayAccountAllowsModelScope(ctx, account, requestedModel) { - return false - } return account.IsSchedulableForModelWithContext(ctx, requestedModel) } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 4774a79..1e2486a 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -873,7 +873,7 @@ func NewGatewayService( rateLimitService: rateLimitService, billingCacheService: billingCacheService, identityService: identityService, - httpUpstream: withModelScopeUpstream(httpUpstream), + httpUpstream: httpUpstream, deferredService: deferredService, claudeTokenProvider: claudeTokenProvider, sessionLimitCache: sessionLimitCache, diff --git a/backend/internal/service/gateway_upstream_transport_error.go b/backend/internal/service/gateway_upstream_transport_error.go index 4940a50..66763b1 100644 --- a/backend/internal/service/gateway_upstream_transport_error.go +++ b/backend/internal/service/gateway_upstream_transport_error.go @@ -39,9 +39,6 @@ var gatewayTransportFailoverBody = []byte(`{"type":"error","error":{"type":"upst // It deliberately does NOT write to the response: the handler owns the // response (failover, or a protocol-correct error once failover is exhausted). func (s *GatewayService) handleUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, event OpsUpstreamErrorEvent) error { - if errors.Is(err, ErrModelScopeDenied) { - return err - } safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") event.ProxyID, event.ProxyName = opsUpstreamProxyAttribution(account) diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 7d833b2..a5530a4 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -817,7 +817,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage if s.cfg != nil { multiplier = s.cfg.Default.RateMultiplier } - if apiKey.GroupID != nil && apiKey.Group != nil && apiKey.Group.Platform != PlatformMixed { + if apiKey.GroupID != nil && apiKey.Group != nil { groupDefault := apiKey.Group.RateMultiplier multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault) } @@ -832,11 +832,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage // 确定计费模型 concreteBillingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) billingModel := concreteBillingModel - if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" && - (apiKey.Group == nil || apiKey.Group.Platform != PlatformMixed || input.ChannelMappedModel != input.OriginalModel) { - // Mixed uses the concrete account identity when the channel did not - // change the model, matching the OpenAI path and catalogue projection. - // Ordinary Messages groups retain their existing source semantics. + if input.BillingModelSource == BillingModelSourceChannelMapped && input.ChannelMappedModel != "" { billingModel = input.ChannelMappedModel } if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" { @@ -852,10 +848,6 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage // 通用兜底(与 OpenAI 路径的 usageBillingModelCandidates 语义对齐): // 选定模型查不到任何价格时回退到实际转发的具体模型。已定价流量不受影响。 billingModel = s.billableModelWithFallback(ctx, apiKey, billingModel, result.UpstreamModel, result.Model) - if apiKey.GroupID != nil && apiKey.Group != nil && apiKey.Group.Platform == PlatformMixed { - base := s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplierForModel(billingModel)) - multiplier, imageMultiplier = computePeakAwareMultipliers(apiKey, base, pricingAt) - } // 确定 RequestedModel(渠道映射前的原始模型) requestedModel := result.Model @@ -876,19 +868,13 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage result.ImageCount > 0 || result.AudioUsage != nil || result.SearchCount > 0, ); responseModel != "" && !strings.EqualFold(responseModel, strings.TrimSpace(billingModel)) { if identified, responseChannelPriced := s.hasIdentifiedResponseModelPricing(ctx, responseModel, apiKey); identified { - responseMultiplier, responseImageMultiplier := multiplier, imageMultiplier - if apiKey.GroupID != nil && apiKey.Group != nil && apiKey.Group.Platform == PlatformMixed { - base := s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplierForModel(responseModel)) - responseMultiplier, responseImageMultiplier = computePeakAwareMultipliers(apiKey, base, pricingAt) - } - responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, responseMultiplier, responseImageMultiplier, pricingAt) + responseCost := s.calculateRecordUsageCost(ctx, result, apiKey, responseModel, multiplier, imageMultiplier, pricingAt) baselineChannelPriced := s.resolveChannelPricing(ctx, billingModel, apiKey) != nil if responseModelBillingAdoptable(cost, responseCost, baselineChannelPriced, responseChannelPriced) { // billingModel 到此为止只是定价查表的入参,后续流程只消费 cost, // 因此这里不改写它,改由日志记录实际生效的计费基准。 logResponseModelBillingApplied("service.gateway", account, result.RequestID, billingModel, responseModel, cost, responseCost) cost = responseCost - multiplier, imageMultiplier = responseMultiplier, responseImageMultiplier } } } diff --git a/backend/internal/service/gemini_cached_content_binding.go b/backend/internal/service/gemini_cached_content_binding.go index 5443c8f..379e108 100644 --- a/backend/internal/service/gemini_cached_content_binding.go +++ b/backend/internal/service/gemini_cached_content_binding.go @@ -113,11 +113,9 @@ func (s *GatewayService) AuthorizeGeminiCachedContent(ctx context.Context, key * return nil, ErrGeminiCachedContentNotFound } } - ctx = ContextWithModelScope(ctx, key.ModelScope) routeCtx, mapped, ok := s.GeminiCatalogRoute(ctx, routed.Group, record.Model) if !ok || mapped != record.ChannelMappedModel || !account.IsModelSupported(mapped) || - account.GetMappedModel(mapped) != record.UpstreamModelID() || - CheckModelScope(routeCtx, record.Model, record.ChannelMappedModel, record.UpstreamModelID()) != nil { + account.GetMappedModel(mapped) != record.UpstreamModelID() { return nil, ErrGeminiCachedContentNotFound } if platform, resolved := ResolvedTargetPlatformFromContext(routeCtx); resolved && platform != account.Platform { diff --git a/backend/internal/service/gemini_cached_content_binding_test.go b/backend/internal/service/gemini_cached_content_binding_test.go index 49375cb..1559b5e 100644 --- a/backend/internal/service/gemini_cached_content_binding_test.go +++ b/backend/internal/service/gemini_cached_content_binding_test.go @@ -20,13 +20,35 @@ func (geminiBindingUnavailableChannelRepo) ListAll(context.Context) ([]Channel, return nil, errors.New("policy unavailable") } +func TestGeminiCachedContentBindingChannelPolicyUnavailableFailsClosed(t *testing.T) { + now := time.Now() + group := &Group{ID: 17, Platform: PlatformGemini, Status: StatusActive, Hydrated: true} + key := &APIKey{ID: 3, UserID: 5, User: &User{ID: 5, Status: StatusActive}, GroupID: &group.ID, Group: group} + record := &GeminiCachedContent{APIKeyID: 3, UserID: 5, GroupID: 17, AccountID: 21, Model: "gemini-live", + ChannelMappedModel: "gemini-live", UpstreamModel: "models/gemini-live", ExpireTime: now.Add(time.Hour)} + account := &Account{ID: 21, Platform: PlatformGemini, Type: AccountTypeAPIKey, GroupIDs: []int64{17}, Credentials: map[string]any{"api_key": "binding-key"}} + var identityErr error + record.UpstreamEndpointIdentity, identityErr = GeminiCachedContentEndpointIdentity(account, record.UpstreamModelID()) + require.NoError(t, identityErr) + svc := &GatewayService{channelService: NewChannelService(geminiBindingUnavailableChannelRepo{}, nil, nil, nil, nil)} + for i := 0; i < 2; i++ { + routed, err := svc.AuthorizeGeminiCachedContent(context.Background(), key, record, account, now) + require.ErrorIs(t, err, ErrGeminiCachedContentNotFound) + require.Nil(t, routed) + } +} + +func (r geminiBindingGroupRepo) GetByIDLite(_ context.Context, id int64) (*Group, error) { + return r.groups[id], nil +} + func TestGeminiCachedContentBindingRechecksChannelPricingRestriction(t *testing.T) { for _, source := range []string{BillingModelSourceRequested, BillingModelSourceChannelMapped, BillingModelSourceUpstream, BillingModelSourceResponse} { for _, allowed := range []bool{false, true} { t.Run(source+"/allowed="+map[bool]string{false: "no", true: "yes"}[allowed], func(t *testing.T) { now := time.Now() group := &Group{ID: 17, Platform: PlatformGemini, Status: StatusActive, Hydrated: true} - key := &APIKey{ID: 3, UserID: 5, User: &User{ID: 5, Status: StatusActive}, GroupID: &group.ID, Group: group, ModelScope: ModelScopeOther} + key := &APIKey{ID: 3, UserID: 5, User: &User{ID: 5, Status: StatusActive}, GroupID: &group.ID, Group: group, ModelScope: ModelScopeAll} record := &GeminiCachedContent{APIKeyID: 3, UserID: 5, GroupID: 17, AccountID: 21, Model: "gemini-public", ChannelMappedModel: "gemini-mapped", UpstreamModel: "models/gemini-upstream", ExpireTime: now.Add(time.Hour)} account := &Account{ID: 21, Platform: PlatformGemini, Type: AccountTypeAPIKey, GroupIDs: []int64{17}, Credentials: map[string]any{ @@ -63,35 +85,13 @@ func TestGeminiCachedContentBindingRechecksChannelPricingRestriction(t *testing. } } -func TestGeminiCachedContentBindingChannelPolicyUnavailableFailsClosed(t *testing.T) { - now := time.Now() - group := &Group{ID: 17, Platform: PlatformGemini, Status: StatusActive, Hydrated: true} - key := &APIKey{ID: 3, UserID: 5, User: &User{ID: 5, Status: StatusActive}, GroupID: &group.ID, Group: group} - record := &GeminiCachedContent{APIKeyID: 3, UserID: 5, GroupID: 17, AccountID: 21, Model: "gemini-live", - ChannelMappedModel: "gemini-live", UpstreamModel: "models/gemini-live", ExpireTime: now.Add(time.Hour)} - account := &Account{ID: 21, Platform: PlatformGemini, Type: AccountTypeAPIKey, GroupIDs: []int64{17}, Credentials: map[string]any{"api_key": "binding-key"}} - var identityErr error - record.UpstreamEndpointIdentity, identityErr = GeminiCachedContentEndpointIdentity(account, record.UpstreamModelID()) - require.NoError(t, identityErr) - svc := &GatewayService{channelService: NewChannelService(geminiBindingUnavailableChannelRepo{}, nil, nil, nil, nil)} - for i := 0; i < 2; i++ { - routed, err := svc.AuthorizeGeminiCachedContent(context.Background(), key, record, account, now) - require.ErrorIs(t, err, ErrGeminiCachedContentNotFound) - require.Nil(t, routed) - } -} - -func (r geminiBindingGroupRepo) GetByIDLite(_ context.Context, id int64) (*Group, error) { - return r.groups[id], nil -} - func TestGeminiCachedContentBindingUsesCurrentOwnerAndExactGroup(t *testing.T) { - for _, changed := range []string{"none", "owner", "user", "unbound", "group disabled", "account unbound", "account changed", "expired", "allowlist", "scope", "mapping changed", "endpoint changed", "key changed", "missing identity"} { + for _, changed := range []string{"none", "owner", "user", "unbound", "group disabled", "account unbound", "account changed", "expired", "allowlist", "mapping changed", "endpoint changed", "key changed", "missing identity"} { t.Run(changed, func(t *testing.T) { now := time.Now() group := &Group{ID: 17, Platform: PlatformGemini, Status: StatusActive, Hydrated: true, RateMultiplier: 0.2} primary := &Group{ID: 9, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, RateMultiplier: 0.15} - key := &APIKey{ID: 3, UserID: 5, User: &User{ID: 5, Status: StatusActive}, GroupID: &primary.ID, Group: primary, RouteGroupIDs: []int64{9, 17}, ModelScope: ModelScopeOther} + key := &APIKey{ID: 3, UserID: 5, User: &User{ID: 5, Status: StatusActive}, GroupID: &primary.ID, Group: primary, RouteGroupIDs: []int64{9, 17}, ModelScope: ModelScopeAll} record := &GeminiCachedContent{APIKeyID: 3, UserID: 5, GroupID: 17, AccountID: 21, Model: "gemini-live", ChannelMappedModel: "gemini-live", UpstreamModel: "models/gemini-live", ExpireTime: now.Add(time.Hour)} account := &Account{ID: 21, Platform: PlatformGemini, Type: AccountTypeAPIKey, GroupIDs: []int64{17}, Credentials: map[string]any{"api_key": "binding-key"}} var identityErr error @@ -115,8 +115,6 @@ func TestGeminiCachedContentBindingUsesCurrentOwnerAndExactGroup(t *testing.T) { record.ExpireTime = now case "allowlist": group.ModelAllowlist = GroupModelAllowlist{Enabled: true, Models: []string{"gemini-other"}} - case "scope": - key.ModelScope = ModelScopeCN case "mapping changed": account.Credentials["model_mapping"] = map[string]any{"gemini-live": "gemini-new"} case "endpoint changed": diff --git a/backend/internal/service/gemini_catalog_projection.go b/backend/internal/service/gemini_catalog_projection.go index 1600eaf..bec19b9 100644 --- a/backend/internal/service/gemini_catalog_projection.go +++ b/backend/internal/service/gemini_catalog_projection.go @@ -69,14 +69,11 @@ func (s *GatewayService) GeminiCatalogRoute(ctx context.Context, group *Group, p return ctx, "", false } model = decision.UpstreamModel - if CheckModelScope(ctx, model) != nil { - return ctx, "", false - } ctx = WithCompositeRouteDecision(ctx, decision) } if s != nil { mapped, _ := s.ResolveChannelMappingAndRestrict(ctx, &group.ID, model) model = mapped.MappedModel } - return ctx, model, IsSafeGeminiModelPathSegment(model) && CheckModelScope(ctx, model) == nil + return ctx, model, IsSafeGeminiModelPathSegment(model) } diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index d585f4e..d020e0e 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -129,9 +129,6 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions( requestIDHeader = idHeader resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) - if errors.Is(err, ErrModelScopeDenied) { - return nil, err - } if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ diff --git a/backend/internal/service/gemini_endpoint_identity_test.go b/backend/internal/service/gemini_endpoint_identity_test.go index 98b5bdc..ab34eb5 100644 --- a/backend/internal/service/gemini_endpoint_identity_test.go +++ b/backend/internal/service/gemini_endpoint_identity_test.go @@ -92,23 +92,3 @@ func TestProjectGeminiCatalogUsesRealMappedTargetsOnly(t *testing.T) { }) require.Error(t, err) } - -func TestProjectGeminiCatalogChecksSelectedAccountsMappedScope(t *testing.T) { - ctx := ContextWithModelScope(context.Background(), ModelScopeOther) - a := &Account{Platform: PlatformGemini, Credentials: map[string]any{"model_mapping": map[string]any{ - "gemini-public": "gpt-5.4", "gemini-valid": "gemini-live", - }}} - res, err := projectGeminiAccountCatalog(ctx, &UpstreamHTTPResult{StatusCode: 200, Body: []byte(`{"models":[{"name":"models/gpt-5.4"},{"name":"models/gemini-live"}]}`)}, a) - require.NoError(t, err) - require.JSONEq(t, `{"models":[{"name":"models/gemini-valid"}]}`, string(res.Body)) - // No account mapping is not permission to expose cross-family entries. - res, err = projectGeminiAccountCatalog(ctx, &UpstreamHTTPResult{StatusCode: 200, Body: []byte(`{"models":[{"name":"models/gpt-5.4"},{"name":"models/gemini-live"}]}`)}, &Account{Platform: PlatformGemini}) - require.NoError(t, err) - require.JSONEq(t, `{"models":[{"name":"models/gemini-live"}]}`, string(res.Body)) - upstream := &httpUpstreamRecorder{} - svc := newGeminiModelsEndpointsTestService([]Account{*a}, upstream) - _, err = svc.SelectAccountForModelsEndpoints(ctx, nil, "gemini-public") - require.Error(t, err) - _, err = svc.ForwardGetModel(ctx, a, "gemini-public") - require.ErrorIs(t, err, ErrModelScopeDenied) -} diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index 8f21582..ed3c599 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -89,7 +89,7 @@ func NewGeminiMessagesCompatService( schedulerSnapshot: schedulerSnapshot, tokenProvider: tokenProvider, rateLimitService: rateLimitService, - httpUpstream: withModelScopeUpstream(httpUpstream), + httpUpstream: httpUpstream, antigravityGatewayService: antigravityGatewayService, cfg: cfg, responseHeaderFilter: compileResponseHeaderFilter(cfg), @@ -529,7 +529,7 @@ func (s *GeminiMessagesCompatService) AntigravityGeminiModelIDs(ctx context.Cont continue } for model := range account.GetModelMapping() { - if isAntigravityGeminiModel(model) && CheckModelScope(ctx, model, account.GetMappedModel(model)) == nil { + if isAntigravityGeminiModel(model) { seen[model] = struct{}{} } } @@ -849,9 +849,6 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex requestIDHeader = idHeader resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) - if errors.Is(err, ErrModelScopeDenied) { - return nil, err - } if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ @@ -1427,9 +1424,6 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin. requestIDHeader = idHeader resp, err = s.httpUpstream.Do(upstreamReq, proxyURL, account.ID, account.Concurrency) - if errors.Is(err, ErrModelScopeDenied) { - return nil, err - } if err != nil { safeErr := sanitizeUpstreamErrorMessage(err.Error()) appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ diff --git a/backend/internal/service/gemini_models_endpoints.go b/backend/internal/service/gemini_models_endpoints.go index d8344d2..f854688 100644 --- a/backend/internal/service/gemini_models_endpoints.go +++ b/backend/internal/service/gemini_models_endpoints.go @@ -59,9 +59,6 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelsEndpoints(ctx contex if len(requestedModel) > 0 && !acc.IsModelSupported(requestedModel[0]) { continue } - if len(requestedModel) > 0 && CheckModelScope(ctx, requestedModel[0], acc.GetMappedModel(requestedModel[0])) != nil { - continue - } if selected == nil { selected = acc continue @@ -148,9 +145,6 @@ func (s *GeminiMessagesCompatService) ForwardListModels(ctx context.Context, acc if account.IsVertexAPIKey() { models := make([]gemini.Model, 0) for _, id := range configuredGeminiModelIDs(account) { - if CheckModelScope(ctx, id, account.GetMappedModel(id)) != nil { - continue - } models = append(models, gemini.FallbackModel(id)) } return geminiModelsJSONResult(http.StatusOK, gemini.ModelsListResponse{Models: models}) @@ -187,9 +181,6 @@ func (s *GeminiMessagesCompatService) ForwardGetModel(ctx context.Context, accou return geminiModelNotFound(model) } upstreamModel := account.GetMappedModel(model) - if err := CheckModelScope(ctx, model, upstreamModel); err != nil { - return nil, err - } if !IsSafeGeminiModelPathSegment(upstreamModel) { return nil, errors.New("invalid mapped model") } @@ -391,7 +382,7 @@ func projectGeminiAccountCatalog(ctx context.Context, res *UpstreamHTTPResult, a if res == nil || res.StatusCode != http.StatusOK { return res, nil } - if len(account.GetModelMapping()) == 0 && ModelScopeFromContext(ctx) == ModelScopeAll { + if len(account.GetModelMapping()) == 0 { return res, nil } var envelope map[string]json.RawMessage @@ -419,9 +410,6 @@ func projectGeminiAccountCatalog(ctx context.Context, res *UpstreamHTTPResult, a } } for _, public := range dedupeAndSortModelIDs(publicIDs) { - if CheckModelScope(ctx, public, id) != nil { - continue - } copy := make(map[string]json.RawMessage, len(model)) for key, value := range model { copy[key] = value @@ -449,11 +437,7 @@ func (s *GeminiMessagesCompatService) GeminiOpenAIModelIDs(ctx context.Context, for i := range accounts { account := &accounts[i] if account.IsGeminiOpenAIProtocol() { - for _, id := range configuredGeminiModelIDs(account) { - if CheckModelScope(ctx, id, account.GetMappedModel(id)) == nil { - ids = append(ids, id) - } - } + ids = append(ids, configuredGeminiModelIDs(account)...) } } return dedupeAndSortModelIDs(ids), nil diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 16c5422..d80db3f 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -16,10 +16,7 @@ type GroupCodexModelsManifestConfig = domain.GroupCodexModelsManifestConfig type ReasoningEffortMapping = domain.ReasoningEffortMapping type Group struct { - // Computed for the authorized key-creation catalogue, not persisted. - SupportedKeyProviders []string - SupportedModelVendors []string - // Absolute Mixed model-vendor multipliers, persisted independently of model prices. + // Legacy storage field; retired vendor multipliers are never used for billing. MixedProviderRateMultipliers map[string]float64 ID int64 Name string diff --git a/backend/internal/service/mixed_billing_bridge_regression_test.go b/backend/internal/service/mixed_billing_bridge_regression_test.go deleted file mode 100644 index bd9b137..0000000 --- a/backend/internal/service/mixed_billing_bridge_regression_test.go +++ /dev/null @@ -1,142 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" - "github.com/stretchr/testify/require" -) - -func TestMixedBillingProviderDeepSeekPreservesOfficialPeakAndOverrides(t *testing.T) { - const upstreamModel = "provider/deepseek-v4-flash" - for _, override := range []string{"", "group", "channel"} { - t.Run(override, func(t *testing.T) { - logs := &openAIRecordUsageLogRepoStub{inserted: true} - svc := newOpenAIRecordUsageServiceForTest(logs, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) - // A real catalog lookup recognizes the prefixed identity. Deliberately - // stale prices prove the existing official-price override still runs. - catalog := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ - "deepseek-v4-flash": {InputCostPerToken: 1e-6, OutputCostPerToken: 2e-6}, - }} - svc.billingService = NewBillingService(svc.cfg, catalog) - svc.resolver = NewModelPricingResolver(nil, svc.billingService) - in := mixedBillingInput(&OpenAIForwardResult{RequestID: "prefixed-official", Model: "alias", UpstreamModel: upstreamModel, - Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 20}}, mixedTestAccount()) - in.PricingAt = time.Date(2026, 10, 13, 9, 30, 0, 0, time.UTC) - card := ChannelModelPricing{Platform: PlatformMixed, Models: []string{upstreamModel}, BillingMode: BillingModeToken, - InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(2e-6)} - if override == "group" { - in.APIKey.Group.ModelPricing = []ChannelModelPricing{card} - } else if override == "channel" { - repo := &mockChannelRepository{ - listAllFn: func(context.Context) ([]Channel, error) { - return []Channel{{ID: 9, Status: StatusActive, GroupIDs: []int64{218}, ModelPricing: []ChannelModelPricing{card}}}, nil - }, - getGroupPlatformsFn: func(context.Context, []int64) (map[int64]string, error) { - return map[int64]string{218: PlatformMixed}, nil - }, - } - svc.resolver = NewModelPricingResolver(NewChannelService(repo, nil, nil, nil, nil), svc.billingService) - } - require.NoError(t, svc.RecordUsage(context.Background(), in)) - if override != "" { - // Explicit per-identity prices must not acquire official peak factors. - require.InDelta(t, (100*1e-6+20*2e-6)*0.1, logs.lastLog.ActualCost, 1e-12) - return - } - expected, err := svc.billingService.CalculateCostUnified(CostInput{ - Ctx: context.Background(), Model: "deepseek-v4-flash", GroupID: in.APIKey.GroupID, Group: in.APIKey.Group, - Tokens: UsageTokens{InputTokens: 100, OutputTokens: 20}, RateMultiplier: 0.1, - PricingAt: in.PricingAt, Resolver: svc.resolver, - }) - require.NoError(t, err) - require.InDelta(t, expected.ActualCost, logs.lastLog.ActualCost, 1e-12) - require.InDelta(t, (100*2.2e-7+20*6.6e-7)*2*0.1, logs.lastLog.ActualCost, 1e-12) - }) - } -} - -func TestMixedBillingResponsesUpstreamUsageAcrossIngress(t *testing.T) { - for _, tc := range []struct { - name, model, usage string - input, read, write, ttl5, ttl1 int - }{ - {"ttl", "claude-sonnet-5-5", `{"input_tokens":130,"output_tokens":5,"cache_read_input_tokens":20,"cache_creation_input_tokens":100,"cache_creation_5m_tokens":30,"cache_creation_1h_tokens":70}`, 10, 20, 100, 30, 70}, - {"ttl-details-only", "claude-sonnet-5-5", `{"input_tokens":130,"output_tokens":5,"cache_read_input_tokens":20,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":70}}`, 10, 20, 100, 30, 70}, - {"deepseek-hit", "deepseek-v4-flash", `{"input_tokens":200,"output_tokens":5,"prompt_cache_hit_tokens":128}`, 72, 128, 0, 0, 0}, - {"explicit-zero", "deepseek-v4-flash", `{"input_tokens":200,"output_tokens":5,"prompt_cache_hit_tokens":128,"input_tokens_details":{"cached_tokens":0}}`, 200, 0, 0, 0, 0}, - } { - var firstCost *float64 - for _, protocol := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, stream := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/%s/stream=%t", tc.name, protocol, stream), func(t *testing.T) { - logs := &openAIRecordUsageLogRepoStub{inserted: true} - svc := newOpenAIRecordUsageServiceForTest(logs, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil, rawChatCompletionsTestConfig()) - svc.resolver = NewModelPricingResolver(nil, svc.billingService) - payload := strings.ReplaceAll(mixedResponsesToolSSE(), `{"input_tokens":9,"output_tokens":4,"total_tokens":13,"input_tokens_details":{"cached_tokens":3}}`, tc.usage) - payload = strings.ReplaceAll(payload, `"model":"actual"`, `"model":"`+tc.model+`"`) - if protocol == APIProtocolResponses && !stream { - payload = `{"id":"resp_1","object":"response","model":"` + tc.model + `","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":` + tc.usage + `}` - } - responses := mixedResponsesForTarget(protocol, APIProtocolResponses, mixedHTTPResponse(200, payload, protocol != APIProtocolResponses || stream)) - upstream := &httpUpstreamRecorder{responses: responses} - svc.httpUpstream = upstream - account := mixedTestAccount() - account.Credentials["model_mapping"] = map[string]any{"alias": tc.model} - result, _, _, err := mixedForward(t, svc, account, protocol, mixedClientBody(protocol, stream)) - require.NoError(t, err) - require.Len(t, upstream.requests, len(responses)) - require.Equal(t, tc.write, result.Usage.CacheCreationInputTokens) - require.Equal(t, tc.ttl5, result.Usage.CacheCreation5mTokens) - require.Equal(t, tc.ttl1, result.Usage.CacheCreation1hTokens) - require.Equal(t, tc.read, result.Usage.CacheReadInputTokens) - in := mixedBillingInput(result, account) - in.PricingAt = time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC) - require.NoError(t, svc.RecordUsage(context.Background(), in)) - require.Equal(t, tc.input, logs.lastLog.InputTokens) - require.Equal(t, tc.ttl5, logs.lastLog.CacheCreation5mTokens) - require.Equal(t, tc.ttl1, logs.lastLog.CacheCreation1hTokens) - expected, err := svc.billingService.CalculateCostUnified(CostInput{Ctx: context.Background(), Model: tc.model, - GroupID: in.APIKey.GroupID, Group: in.APIKey.Group, Resolver: svc.resolver, PricingAt: in.PricingAt, RateMultiplier: 0.1, - Tokens: UsageTokens{InputTokens: tc.input, OutputTokens: 5, CacheReadTokens: tc.read, CacheCreationTokens: tc.write, CacheCreation5mTokens: tc.ttl5, CacheCreation1hTokens: tc.ttl1}}) - require.NoError(t, err) - require.InDelta(t, expected.TotalCost, logs.lastLog.TotalCost, 1e-12) - require.InDelta(t, canonicalUsageActualCost(expected), logs.lastLog.ActualCost, 1e-12) - if firstCost != nil { - require.InDelta(t, *firstCost, logs.lastLog.ActualCost, 1e-12) - } - cost := logs.lastLog.ActualCost - firstCost = &cost - }) - } - } - } -} - -func TestMixedBillingAnthropicPartialTTLDeltaPreservesOmittedBucket(t *testing.T) { - var usage ClaudeUsage - for _, step := range []struct { - raw string - ttl5, ttl1, aggregate int - }{ - {`{"input_tokens":10,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":70}}`, 30, 70, 100}, - {`{"output_tokens":5,"cache_creation_input_tokens":105,"cache_creation":{"ephemeral_5m_input_tokens":35}}`, 35, 70, 105}, - {`{"cache_creation":{"ephemeral_1h_input_tokens":0}}`, 35, 0, 35}, - {`{"cache_creation":{"ephemeral_5m_input_tokens":0}}`, 0, 0, 0}, - {`{"output_tokens":6}`, 0, 0, 0}, - } { - var parsed apicompat.AnthropicUsage - require.NoError(t, json.Unmarshal([]byte(step.raw), &parsed)) - mergeAnthropicUsage(&usage, parsed) - require.Equal(t, step.ttl5, usage.CacheCreation5mTokens) - require.Equal(t, step.ttl1, usage.CacheCreation1hTokens) - require.Equal(t, step.aggregate, usage.CacheCreationInputTokens) - } -} diff --git a/backend/internal/service/mixed_catalogue_billing_agreement_test.go b/backend/internal/service/mixed_catalogue_billing_agreement_test.go deleted file mode 100644 index 9dc2ae3..0000000 --- a/backend/internal/service/mixed_catalogue_billing_agreement_test.go +++ /dev/null @@ -1,68 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestMixedCatalogueChannelBillingAgreement(t *testing.T) { - for _, tc := range []struct { - name, request, channelTarget, expected string - }{ - {"unchanged", "kimi-k2", "", "glm-5"}, - {"identity-map", "kimi-k2", "kimi-k2", "glm-5"}, - {"actual-map", "gpt-5.4", "kimi-k2", "kimi-k2"}, - {"unpriced-map", "gpt-5.4", "qa-unpriced-alias", "glm-5"}, - } { - t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() - group := &Group{ID: 2, Platform: PlatformMixed, MixedProviderRateMultipliers: map[string]float64{"kimi": 0, "zhipu": 0.8}} - ch := Channel{ID: 1, Status: StatusActive, GroupIDs: []int64{2}, BillingModelSource: BillingModelSourceChannelMapped} - if tc.channelTarget != "" { - ch.ModelMapping = map[string]map[string]string{PlatformMixed: {tc.request: tc.channelTarget}} - } - channels := newTestChannelService(makeStandardRepo(ch, map[int64]string{2: PlatformMixed})) - mapping := channels.ResolveChannelMapping(ctx, 2, tc.request) - account := &Account{ID: 4, Platform: PlatformMixed, Type: AccountTypeAPIKey, - Credentials: map[string]any{"model_mapping": map[string]any{mapping.MappedModel: "glm-5"}}} - billing := newGatewayRecordUsageServiceForTest(&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}).billingService - billing.fallbackPrices = mixedRateTestPricing() - gateway := &GatewayService{channelService: channels, billingService: billing, resolver: NewModelPricingResolver(channels, billing)} - cat := groupModelsCatalog{scopeMappings: []modelScopeMapping{{platform: PlatformMixed, mapping: map[string]string{mapping.MappedModel: "glm-5"}}}} - require.Equal(t, []string{tc.expected}, gateway.mixedCatalogueBillingModels(ctx, group, tc.request, cat)) - key := &APIKey{ID: 3, UserID: 1, User: &User{ID: 1}, GroupID: &group.ID, Group: group} - fields := mapping.ToUsageFields(tc.request, "glm-5") - expected, err := billing.CalculateCost(tc.expected, UsageTokens{InputTokens: 100, OutputTokens: 50}, group.RateMultiplierForModel(tc.expected)) - require.NoError(t, err) - for _, path := range []string{"openai", "anthropic"} { - t.Run(path, func(t *testing.T) { - logs := &openAIRecordUsageLogRepoStub{inserted: true} - users := &openAIRecordUsageUserRepoStub{} - if path == "openai" { - svc := newOpenAIRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}, nil) - svc.billingService.fallbackPrices = mixedRateTestPricing() - svc.resolver = NewModelPricingResolver(channels, svc.billingService) - require.NoError(t, svc.RecordUsage(ctx, &OpenAIRecordUsageInput{APIKey: key, User: key.User, Account: account, ChannelUsageFields: fields, - Result: &OpenAIForwardResult{RequestID: "catalogue-agreement", Model: tc.request, UpstreamModel: "glm-5", BillingModel: "glm-5", Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 50}}, - })) - } else { - svc := newGatewayRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}) - svc.billingService.fallbackPrices = mixedRateTestPricing() - svc.resolver = NewModelPricingResolver(channels, svc.billingService) - require.NoError(t, svc.RecordUsage(ctx, &RecordUsageInput{APIKey: key, User: key.User, Account: account, ChannelUsageFields: fields, - Result: &ForwardResult{RequestID: "catalogue-agreement", Model: "glm-5", UpstreamModel: "glm-5", Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}}, - })) - } - require.NotNil(t, logs.lastLog) - require.InDelta(t, expected.ActualCost, logs.lastLog.ActualCost, 1e-12) - require.Equal(t, group.RateMultiplierForModel(tc.expected), logs.lastLog.RateMultiplier) - require.InDelta(t, expected.ActualCost, users.lastAmount, 1e-12) - }) - } - }) - } -} diff --git a/backend/internal/service/mixed_deepseek_history_wiring_test.go b/backend/internal/service/mixed_deepseek_history_wiring_test.go deleted file mode 100644 index ef211c4..0000000 --- a/backend/internal/service/mixed_deepseek_history_wiring_test.go +++ /dev/null @@ -1,70 +0,0 @@ -//go:build unit - -package service - -import ( - "fmt" - "io" - "testing" - - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -// The aggregation entry must use the same existing DeepSeek history cleanup -// as native forwarding. This is not evidence of any particular live rejection. -func TestMixedDeepSeekHistoryCleanupBeforeChatConversion(t *testing.T) { - for _, model := range []string{"deepseek-v4-pro-0813", "glm-5.3"} { - for _, stream := range []bool{false, true} { - for _, distinctID := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/stream=%t/distinct=%t", model, stream, distinctID), func(t *testing.T) { - secondID := "call_first" - if distinctID { - secondID = "call_second" - } - body := []byte(fmt.Sprintf(`{"model":%q,"stream":%t,"input":[ - {"type":"function_call","call_id":"call_first","name":"lookup","arguments":"{\"id\":1}"}, - {"type":"function_call_output","call_id":"call_first","output":"first-result"}, - {"type":"function_call","call_id":%q,"name":"lookup","arguments":"{\"id\":1}"}, - {"type":"function_call_output","call_id":%q,"output":"later-result"} - ],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]}`, model, stream, secondID, secondID)) - upstreamBody := mixedChatToolJSON - if stream { - upstreamBody = mixedChatToolSSE() - } - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(APIProtocolResponses, APIProtocolChatCompletions, mixedHTTPResponse(200, upstreamBody, stream))} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - account := mixedTestAccount() - account.Credentials["model_mapping"] = map[string]any{model: model} - _, _, _, err := mixedForward(t, svc, account, APIProtocolResponses, body) - require.NoError(t, err) - require.Len(t, upstream.requests, 2) - require.Equal(t, "/v1/responses", upstream.requests[0].URL.Path) - require.Equal(t, "/v1/chat/completions", upstream.requests[1].URL.Path) - outbound, err := io.ReadAll(upstream.requests[1].Body) - require.NoError(t, err) - calls, outputs := 0, 0 - var resultTexts []string - for _, message := range gjson.GetBytes(outbound, "messages").Array() { - calls += len(message.Get("tool_calls").Array()) - if message.Get("role").String() == "tool" { - outputs++ - resultTexts = append(resultTexts, message.Get("content").String()) - } - } - if model == "deepseek-v4-pro-0813" && !distinctID { - require.Equal(t, 1, calls) - require.Equal(t, 1, outputs) - require.Equal(t, []string{"first-result"}, resultTexts) - } else { - require.Equal(t, 2, calls, "do not dedupe identical arguments with distinct IDs or change non-DeepSeek behavior") - require.Equal(t, 2, outputs) - if distinctID { - require.Equal(t, []string{"first-result", "later-result"}, resultTexts) - } - } - }) - } - } - } -} diff --git a/backend/internal/service/mixed_gateway_test.go b/backend/internal/service/mixed_gateway_test.go deleted file mode 100644 index af6bd4d..0000000 --- a/backend/internal/service/mixed_gateway_test.go +++ /dev/null @@ -1,464 +0,0 @@ -//go:build unit - -package service - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -func mixedTestAccount() *Account { - return &Account{ID: 702, Platform: PlatformMixed, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, Concurrency: 1, - Credentials: map[string]any{"base_url": "http://upstream.example/v1", "api_key": "test-secret", "model_mapping": map[string]any{"alias": "actual", "actual": "wrong-double-mapping"}}, - Extra: map[string]any{"openai_responses_supported": true, "openai_passthrough": true}} -} - -func mixedHTTPResponse(status int, body string, stream bool) *http.Response { - ct := "application/json" - if stream { - ct = "text/event-stream" - } - return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": []string{ct}}, Body: io.NopCloser(strings.NewReader(body))} -} - -func mixedRefusal(protocol string) *http.Response { - message := map[string]string{APIProtocolChatCompletions: "Chat Completions protocol is not supported for this model", APIProtocolResponses: "model does not support openai responses protocol", APIProtocolAnthropic: "Anthropic messages protocol is not supported for this model"}[protocol] - b, _ := json.Marshal(map[string]any{"error": map[string]string{"code": "model_not_available", "message": message}}) - return mixedHTTPResponse(404, string(b), false) -} - -// These expectations are deliberately independent of the production dispatcher. -func mixedExpectedProtocolOrder(ingress string) []string { - switch ingress { - case APIProtocolResponses: - return []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} - case APIProtocolAnthropic: - return []string{APIProtocolAnthropic, APIProtocolChatCompletions, APIProtocolResponses} - default: - return []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} - } -} - -// Supply real HTTP refusals for every preceding protocol. mixedForward always -// enters the production dispatcher, including tests targeting a converted path. -func mixedResponsesForTarget(ingress, target string, response *http.Response) []*http.Response { - responses := []*http.Response{} - for _, protocol := range mixedExpectedProtocolOrder(ingress) { - if protocol == target { - return append(responses, response) - } - responses = append(responses, mixedRefusal(protocol)) - } - panic("unknown Mixed test target") -} - -func mixedExpectedAttempts(ingress, target string) int { - for i, protocol := range mixedExpectedProtocolOrder(ingress) { - if protocol == target { - return i + 1 - } - } - panic("unknown Mixed test target") -} - -const mixedChatToolJSON = `{"id":"cc_1","object":"chat.completion","model":"actual","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"id\":1}"}}]},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13,"prompt_tokens_details":{"cached_tokens":3}}}` - -func mixedChatToolSSE() string { - return "data: " + `{"id":"cc_1","object":"chat.completion.chunk","model":"actual","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"id\":1}"}}]},"finish_reason":null}]}` + "\n\n" + - "data: " + `{"id":"cc_1","object":"chat.completion.chunk","model":"actual","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13,"prompt_tokens_details":{"cached_tokens":3}}}` + "\n\ndata: [DONE]\n\n" -} - -func mixedClientBody(protocol string, stream bool) []byte { - var fields string - switch protocol { - case APIProtocolResponses: - fields = `"input":[{"type":"function_call","call_id":"prior","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"prior","output":"result"}],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"id":{"type":"integer"}}}}]` - case APIProtocolChatCompletions: - fields = `"messages":[{"role":"assistant","tool_calls":[{"id":"prior","type":"function","function":{"name":"lookup","arguments":"{}"}}]},{"role":"tool","tool_call_id":"prior","content":"result"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object","properties":{"id":{"type":"integer"}}}}}]` - case APIProtocolAnthropic: - fields = `"max_tokens":128,"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"prior","name":"lookup","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"prior","content":"result"}]}],"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{"id":{"type":"integer"}}}}]` - } - return []byte(fmt.Sprintf(`{"model":"alias","stream":%t,%s}`, stream, fields)) -} - -func mixedForward(t *testing.T, svc *OpenAIGatewayService, account *Account, protocol string, body []byte) (*OpenAIForwardResult, *httptest.ResponseRecorder, *gin.Context, error) { - t.Helper() - recorder := httptest.NewRecorder() - c, _ := gin.CreateTestContext(recorder) - c.Request = httptest.NewRequest(http.MethodPost, protocolUpstreamEndpoint(protocol), bytes.NewReader(body)) - c.Set("api_key", &APIKey{Group: &Group{Platform: PlatformMixed}}) - var result *OpenAIForwardResult - var err error - switch protocol { - case APIProtocolResponses: - result, err = svc.Forward(c.Request.Context(), c, account, body) - case APIProtocolChatCompletions: - result, err = svc.ForwardAsChatCompletions(c.Request.Context(), c, account, body, "", "") - case APIProtocolAnthropic: - result, err = svc.ForwardAsAnthropic(c.Request.Context(), c, account, body, "", "") - } - return result, recorder, c, err -} - -func TestMixedChatFallbackAllIngressToolsAndUsage(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, protocol := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, stream := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/stream=%t", protocol, stream), func(t *testing.T) { - account := mixedTestAccount() - before, _ := json.Marshal(account) - upstreamBody := mixedChatToolJSON - if stream { - upstreamBody = mixedChatToolSSE() - } - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(protocol, APIProtocolChatCompletions, mixedHTTPResponse(200, upstreamBody, stream))} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, recorder, c, err := mixedForward(t, svc, account, protocol, mixedClientBody(protocol, stream)) - require.NoError(t, err) - require.Len(t, upstream.requests, mixedExpectedAttempts(protocol, APIProtocolChatCompletions)) - require.Equal(t, "/v1/chat/completions", upstream.lastReq.URL.Path) - require.Equal(t, "/v1/chat/completions", GetActualOpenAIUpstreamEndpoint(c)) - require.Equal(t, "Bearer test-secret", upstream.lastReq.Header.Get("Authorization")) - require.Equal(t, "actual", gjson.GetBytes(upstream.lastBody, "model").String()) - require.Contains(t, string(upstream.lastBody), "prior") - require.Contains(t, string(upstream.lastBody), "result") - require.Contains(t, recorder.Body.String(), "call_1") - require.Contains(t, recorder.Body.String(), "lookup") - require.Equal(t, "actual", result.UpstreamModel) - require.Equal(t, 9, result.Usage.InputTokens) - require.Equal(t, 4, result.Usage.OutputTokens) - require.Equal(t, 3, result.Usage.CacheReadInputTokens) - if protocol == APIProtocolAnthropic { - require.Contains(t, recorder.Body.String(), "tool_use") - } - if protocol == APIProtocolResponses { - require.Contains(t, recorder.Body.String(), "function_call") - } - after, _ := json.Marshal(account) - require.JSONEq(t, string(before), string(after)) - }) - } - } -} - -func TestMixedFallbackOrderBoundedAndMapsOriginalOnce(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, protocol := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - t.Run(protocol, func(t *testing.T) { - order := mixedExpectedProtocolOrder(protocol) - upstream := &httpUpstreamRecorder{responses: []*http.Response{mixedRefusal(order[0]), mixedRefusal(order[1]), mixedRefusal(order[2])}} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - account := mixedTestAccount() - _, _, c, err := mixedForward(t, svc, account, protocol, mixedClientBody(protocol, false)) - require.Error(t, err) - require.Len(t, upstream.requests, 3) - for i, req := range upstream.requests { - require.Equal(t, protocolUpstreamEndpoint(order[i]), req.URL.Path) - require.Equal(t, "upstream.example", req.URL.Host) - require.Equal(t, "actual", gjson.GetBytes(upstream.bodies[i], "model").String()) - require.Contains(t, string(upstream.bodies[i]), "prior") - if order[i] == APIProtocolAnthropic { - require.Equal(t, "test-secret", req.Header.Get("X-Api-Key")) - require.Empty(t, req.Header.Get("Authorization")) - } - } - require.Equal(t, protocolUpstreamEndpoint(order[2]), GetActualOpenAIUpstreamEndpoint(c)) - }) - } -} - -func TestMixedExplicitProtocolRefusalStrict(t *testing.T) { - positive := []struct{ protocol, message string }{ - {APIProtocolResponses, "model does not support openai responses protocol"}, - {APIProtocolResponses, "qwen3.8-27b does not support openai_responses protocol"}, - {APIProtocolChatCompletions, "qwen3.8-27b does not support openai_chat_completions protocol"}, - {APIProtocolChatCompletions, "Chat Completions protocol is not supported for this model"}, - {APIProtocolChatCompletions, "Cannot POST /v1/chat/completions"}, - {APIProtocolAnthropic, "Anthropic messages protocol is not supported for this model"}, - {APIProtocolAnthropic, "model does not support anthropic_messages protocol"}, - } - for _, tt := range positive { - body, _ := json.Marshal(map[string]any{"error": map[string]string{"message": tt.message}}) - for _, status := range []int{400, 404, 405} { - require.True(t, isExplicitOpenAIProtocolRefusal(status, body, tt.protocol), tt.message) - } - for _, status := range []int{200, 401, 403, 429, 500, 502, 503} { - require.False(t, isExplicitOpenAIProtocolRefusal(status, body, tt.protocol)) - } - } - negative := []string{ - `Not Found`, `{"error":{"message":"model not available","code":"model_not_available"}}`, - `{"error":{"message":"Unsupported parameter for Responses protocol"}}`, - `{"error":{"message":"Responses protocol is not supported","param":"tools"}}`, - `{"error":{"message":"Responses protocol is not supported","code":"unsupported_parameter"}}`, - `{"error":{"message":"Unsupported Responses tool schema"}}`, - `{"error":{"message":"Unsupported tool for Chat Completions protocol"}}`, - `{"error":{"message":"Cannot POST /v1/responses"}}`, - } - for _, body := range negative { - require.False(t, isExplicitOpenAIProtocolRefusal(404, []byte(body), APIProtocolChatCompletions), body) - } - for _, body := range negative[0:7] { - require.False(t, isExplicitOpenAIProtocolRefusal(404, []byte(body), APIProtocolResponses), body) - } -} - -func TestMixedNeverFallsBackOnOtherErrors(t *testing.T) { - for _, status := range []int{400, 401, 403, 404, 405, 429, 500, 502} { - t.Run(fmt.Sprint(status), func(t *testing.T) { - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(status, `{"error":{"message":"Unknown model or invalid parameter","code":"model_not_found"}}`, false)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - _, _, _, err := mixedForward(t, svc, mixedTestAccount(), APIProtocolChatCompletions, mixedClientBody(APIProtocolChatCompletions, false)) - require.Error(t, err) - require.Len(t, upstream.requests, 1) - }) - } - upstream := &httpUpstreamRecorder{err: errors.New("network unavailable")} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - _, _, _, err := mixedForward(t, svc, mixedTestAccount(), APIProtocolResponses, mixedClientBody(APIProtocolResponses, false)) - require.Error(t, err) - require.Len(t, upstream.requests, 1) -} - -func TestMixedRefusalDoesNotRetryCanceledOrCommitted(t *testing.T) { - for _, committed := range []bool{false, true} { - ctx, cancel := context.WithCancel(context.Background()) - state := &openAIProtocolFallbackState{clientCtx: ctx, visited: map[string]bool{}, order: []string{APIProtocolChatCompletions, APIProtocolResponses}} - attempt := context.WithValue(ctx, openAIProtocolFallbackKey{}, state) - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil) - if committed { - c.String(200, "started") - } else { - cancel() - } - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig()} - require.Nil(t, svc.protocolRefusalFallback(attempt, c, mixedTestAccount(), APIProtocolChatCompletions, 404, []byte(`{"error":{"message":"Chat Completions protocol is not supported"}}`), false)) - cancel() - } -} - -func TestMixedPlatformBoundariesAndValidation(t *testing.T) { - account := mixedTestAccount() - require.True(t, account.IsMixed()) - require.True(t, account.IsOpenAICompatible()) - require.False(t, account.IsOpenAI()) - require.False(t, account.IsOpenAIApiKey()) - require.False(t, account.IsCNProvider()) - require.False(t, account.IsPoolMode()) - require.Equal(t, PlatformMixed, NormalizeOpenAICompatiblePlatform(PlatformMixed)) - require.Contains(t, groupCatalogPlatforms(), PlatformMixed) - require.Contains(t, schedulerSnapshotPlatforms(), PlatformMixed) - require.True(t, IsAllowedQuotaPlatform(PlatformMixed)) - require.True(t, isConcreteRequestPlatform(PlatformMixed)) - require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions)) - for _, cap := range []OpenAIEndpointCapability{OpenAIEndpointCapabilityEmbeddings, OpenAIEndpointCapabilityLive, OpenAIEndpointCapabilityAlphaSearch, OpenAIEndpointCapabilityGrokMediaGeneration} { - require.False(t, account.SupportsOpenAIEndpointCapability(cap)) - } - require.Equal(t, "platform_mismatch", openAICompatibleAccountEligibilityFailureReasonBeforeProfit(context.Background(), account, PlatformOpenAI, "alias", false, "")) - require.True(t, CrossProtocolConversionAllowed(&APIKey{Group: &Group{Platform: PlatformMixed}})) - require.False(t, CrossProtocolConversionAllowed(&APIKey{Group: &Group{Platform: PlatformOpenAI}})) - require.False(t, CrossProtocolConversionAllowed(&APIKey{Group: &Group{Platform: PlatformComposite}})) - for _, typ := range []string{AccountTypeOAuth, AccountTypeSetupToken, AccountTypeBedrock, AccountTypeCloudflare, AccountTypeUpstream} { - account.Type = typ - require.Error(t, validateMixedAccount(account)) - } - account.Type = AccountTypeAPIKey - for _, base := range []string{"", "api.example", "https://user:secret@api.example", "https://api.example?x=1", "ftp://api.example"} { - account.Credentials["base_url"] = base - require.Error(t, validateMixedAccount(account)) - } - account.Credentials["base_url"] = "https://api.example" - account.Credentials["api_key"] = "" - require.Error(t, validateMixedAccount(account)) -} - -func TestMixedAnthropicSameOriginAndExplicitAuth(t *testing.T) { - account := mixedTestAccount() - account.Credentials["api_base_urls"] = map[string]any{APIProtocolAnthropic: "http://evil.example/anthropic"} - _, err := genericOpenAIAnthropicBaseURL(account) - require.Error(t, err) - account.Credentials["api_base_urls"] = map[string]any{APIProtocolAnthropic: "http://upstream.example/anthropic"} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig()} - target, err := svc.nativeAnthropicTargetURL(account) - require.NoError(t, err) - require.Equal(t, "http://upstream.example/anthropic/v1/messages", target) - account.Extra[anthropicAPIKeyAuthSchemeExtraKey] = AnthropicAPIKeyAuthSchemeAuthorizationBearer - header := http.Header{} - setAnthropicAPIKeyAuthHeader(header, account, "test-secret", "http://upstream.example/anthropic") - require.Equal(t, "Bearer test-secret", header.Get("Authorization")) - require.Empty(t, header.Get("X-Api-Key")) - account.Credentials["base_url"] = "" - _, err = svc.openAIChatCompletionsTargetURL(account) - require.Error(t, err) - _, err = buildOpenAIAPIKeyModelsRequest(context.Background(), account, func(s string) (string, error) { return s, nil }) - require.Error(t, err) -} - -func mixedResponsesToolSSE() string { - return strings.Join([]string{ - `event: response.created`, `data: {"type":"response.created","response":{"id":"resp_1","model":"actual","status":"in_progress","output":[]}}`, "", - `event: response.output_item.added`, `data: {"type":"response.output_item.added","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"lookup","arguments":"","status":"in_progress"}}`, "", - `event: response.function_call_arguments.delta`, `data: {"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_1","delta":"{\"id\":1}"}`, "", - `event: response.function_call_arguments.done`, `data: {"type":"response.function_call_arguments.done","output_index":0,"item_id":"fc_1","arguments":"{\"id\":1}"}`, "", - `event: response.output_item.done`, `data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"id\":1}","status":"completed"}}`, "", - `event: response.completed`, `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"actual","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"id\":1}","status":"completed"}],"usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13,"input_tokens_details":{"cached_tokens":3}}}}`, "", "", - }, "\n") -} - -func TestMixedFallbackSuccessPreservesClientFormatsAndTools(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, protocol := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, target := range []string{APIProtocolResponses, APIProtocolAnthropic} { - for _, stream := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/to=%s/stream=%t", protocol, target, stream), func(t *testing.T) { - var upstreamResponse *http.Response - if target == APIProtocolResponses { - if protocol == APIProtocolResponses && !stream { - upstreamResponse = mixedHTTPResponse(200, `{"id":"resp_1","object":"response","model":"actual","status":"completed","output":[{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{\"id\":1}"}],"usage":{"input_tokens":9,"output_tokens":4}}`, false) - } else { - upstreamResponse = mixedHTTPResponse(200, mixedResponsesToolSSE(), true) - } - } else { - if protocol == APIProtocolAnthropic && !stream { - upstreamResponse = mixedHTTPResponse(200, `{"id":"msg_1","type":"message","role":"assistant","model":"actual","content":[{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"query":"status"}}],"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}`, false) - } else { - // Real compatibility providers can emit tool_use with end_turn. - upstreamResponse = mixedHTTPResponse(200, strings.ReplaceAll(toolAnthropicSSEStream(), `"stop_reason":"tool_use"`, `"stop_reason":"end_turn"`), true) - } - } - responses := mixedResponsesForTarget(protocol, target, upstreamResponse) - attempts := len(responses) - upstream := &httpUpstreamRecorder{responses: responses} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, c, err := mixedForward(t, svc, mixedTestAccount(), protocol, mixedClientBody(protocol, stream)) - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, upstream.requests, attempts) - require.Contains(t, rec.Body.String(), "lookup") - if protocol == APIProtocolAnthropic && target == APIProtocolAnthropic { - require.Contains(t, rec.Body.String(), `"stop_reason":"tool_use"`) - require.NotContains(t, rec.Body.String(), `"stop_reason":"end_turn"`) - } - require.Equal(t, protocolUpstreamEndpoint(target), GetActualOpenAIUpstreamEndpoint(c)) - require.Equal(t, "actual", result.UpstreamModel) - if target == APIProtocolResponses { - require.Equal(t, 9, result.Usage.InputTokens) - require.Equal(t, 4, result.Usage.OutputTokens) - } else { - require.Equal(t, 10, result.Usage.InputTokens) - require.Equal(t, 5, result.Usage.OutputTokens) - } - for _, body := range upstream.bodies { - require.Equal(t, "actual", gjson.GetBytes(body, "model").String()) - require.Contains(t, string(body), "prior") - } - }) - } - } - } -} - -func TestMixed200SSEErrorNeverReplays(t *testing.T) { - for _, protocol := range []string{APIProtocolResponses, APIProtocolAnthropic, APIProtocolChatCompletions} { - t.Run(protocol, func(t *testing.T) { - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(200, "data: "+`{"error":{"message":"Chat Completions protocol is not supported"}}`+"\n\n", true)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - _, rec, _, err := mixedForward(t, svc, mixedTestAccount(), protocol, mixedClientBody(protocol, true)) - require.Error(t, err) - require.Contains(t, rec.Body.String(), `"error"`) - require.NotContains(t, rec.Body.String(), "response.completed") - require.NotContains(t, rec.Body.String(), "message_stop") - require.NotContains(t, rec.Body.String(), "[DONE]") - require.Len(t, upstream.requests, 1) - }) - } -} - -type mixedGroupRepo struct { - GroupRepository - group *Group -} - -func (r *mixedGroupRepo) GetByID(context.Context, int64) (*Group, error) { return r.group, nil } - -func TestMixedAccountGroupBindings(t *testing.T) { - for _, platform := range []string{PlatformMixed, PlatformComposite, PlatformOpenAI, PlatformAnthropic} { - repo := &mixedGroupRepo{group: &Group{Platform: platform}} - account := mixedTestAccount() - err := validateMixedGroupBindings(context.Background(), repo, account, []int64{1}) - if platform == PlatformMixed || platform == PlatformComposite { - require.NoError(t, err) - } else { - require.Error(t, err) - } - account.Platform = PlatformOpenAI - err = validateMixedGroupBindings(context.Background(), repo, account, []int64{1}) - if platform == PlatformMixed { - require.Error(t, err) - } else { - require.NoError(t, err) - } - } - account := mixedTestAccount() - _, err := buildAccountForCreate(&CreateAccountInput{Platform: PlatformMixed, Type: AccountTypeOAuth, Credentials: account.Credentials}, nil) - require.Error(t, err) - _, err = buildAccountForCreate(&CreateAccountInput{Platform: PlatformMixed, Type: AccountTypeAPIKey, Credentials: account.Credentials}, nil) - require.NoError(t, err) - groupRepo := &groupPlatformRepoStub{group: &Group{ID: 7, Platform: PlatformMixed, RequireOAuthOnly: true, RequirePrivacySet: true}} - admin := &adminServiceImpl{groupRepo: groupRepo} - group, err := admin.UpdateGroup(context.Background(), 7, &UpdateGroupInput{}) - require.NoError(t, err) - require.False(t, group.RequireOAuthOnly) - require.False(t, group.RequirePrivacySet) -} - -func TestMixedConnectivityTestUsesSameDispatcher(t *testing.T) { - upstream := &httpUpstreamRecorder{responses: []*http.Response{mixedRefusal(APIProtocolChatCompletions), mixedHTTPResponse(200, mixedResponsesTextSSE(), true)}} - gateway := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - svc := &AccountTestService{openaiGatewayService: gateway} - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest("POST", "/test", nil) - require.NoError(t, svc.testMixedAccountConnection(c, mixedTestAccount(), "alias", "Hi")) - require.Len(t, upstream.requests, 2) - require.Equal(t, "/v1/responses", upstream.lastReq.URL.Path) - require.Contains(t, rec.Body.String(), "Hello") - require.NotContains(t, rec.Body.String(), "/v1/") - require.Contains(t, rec.Body.String(), `"success":true`) -} - -func TestMixedTextOnlyAndExactModelBoundary(t *testing.T) { - account := mixedTestAccount() - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) - for _, body := range []string{ - `{"model":"alias","audio":{"voice":"alloy"}}`, - `{"model":"alias","modalities":["text","audio"]}`, - `{"model":"alias","tools":[{"type":"image_generation"}]}`, - `{"model":"alias","tools":[{"type":"web_search"}]}`, - `{"model":"alias","context_management":[]}`, - } { - require.False(t, openAIProtocolFallbackEligible(c, account, []byte(body))) - } - c.Request = httptest.NewRequest(http.MethodPost, "/v1/audio/speech", nil) - require.False(t, openAIProtocolFallbackEligible(c, account, []byte(`{"model":"alias"}`))) - ctx := WithOpenAIMessagesKeyRoute(context.Background()) - require.Equal(t, "vendor/gpt-6.1-sol-high", openAIMessagesKeyRouteModel(ctx, &Group{Platform: PlatformMixed}, "vendor/gpt-6.1-sol-high")) - ctx = WithResolvedTargetPlatform(ctx, PlatformMixed) - require.False(t, openAIMessagesKeyRouteGroupAllowed(ctx, &Group{Platform: PlatformComposite})) - require.Equal(t, "claude-sonnet-4-5", openAIMessagesKeyRouteModel(ctx, &Group{Platform: PlatformComposite}, "claude-sonnet-4-5")) - require.Empty(t, (&Group{Platform: PlatformMixed, Name: "Grok mixed"}).ResolveMessagesDispatchModel("claude-sonnet-4-5")) -} diff --git a/backend/internal/service/mixed_key_scope_forward_test.go b/backend/internal/service/mixed_key_scope_forward_test.go deleted file mode 100644 index 3430c12..0000000 --- a/backend/internal/service/mixed_key_scope_forward_test.go +++ /dev/null @@ -1,66 +0,0 @@ -//go:build unit - -package service - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -func TestMixedScopedKeyAllIngressForwarding(t *testing.T) { - for scope, model := range map[string]string{"openai": "gpt-5", "anthropic": "claude-4", "cn": "qwen3", "other": "gemini-3"} { - for _, protocol := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, deny := range []bool{false, true} { - t.Run(scope+"/"+protocol+"/deny="+map[bool]string{false: "false", true: "true"}[deny], func(t *testing.T) { - account := mixedTestAccount() - target := model - if deny { - target = "gpt-5" - if scope == "openai" { - target = "qwen3" - } - } - account.Credentials["model_mapping"] = map[string]any{model: target} - before, err := json.Marshal(account) - require.NoError(t, err) - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(protocol, APIProtocolChatCompletions, mixedHTTPResponse(200, strings.ReplaceAll(mixedChatToolJSON, "actual", target), false))} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: withModelScopeUpstream(upstream)} - body := bytes.ReplaceAll(mixedClientBody(protocol, false), []byte(`"alias"`), []byte(`"`+model+`"`)) - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest(http.MethodPost, protocolUpstreamEndpoint(protocol), bytes.NewReader(body)) - c.Request = c.Request.WithContext(ContextWithModelScope(c.Request.Context(), scope)) - c.Set("api_key", &APIKey{ModelScope: scope, Group: &Group{Platform: PlatformMixed}}) - var result *OpenAIForwardResult - switch protocol { - case APIProtocolResponses: - result, err = svc.Forward(c.Request.Context(), c, account, body) - case APIProtocolChatCompletions: - result, err = svc.ForwardAsChatCompletions(c.Request.Context(), c, account, body, "", "") - case APIProtocolAnthropic: - result, err = svc.ForwardAsAnthropic(c.Request.Context(), c, account, body, "", "") - } - if deny { - require.ErrorIs(t, err, ErrModelScopeDenied) - require.Empty(t, upstream.requests, "no cross-scope network or protocol retry") - } else { - require.NoError(t, err) - require.Len(t, upstream.requests, mixedExpectedAttempts(protocol, APIProtocolChatCompletions)) - require.Equal(t, target, result.UpstreamModel) - require.Equal(t, 9, result.Usage.InputTokens) - require.Equal(t, 4, result.Usage.OutputTokens) - } - after, err := json.Marshal(account) - require.NoError(t, err) - require.JSONEq(t, string(before), string(after), "scope does not change credentials, mapping, rate or platform") - }) - } - } - } -} diff --git a/backend/internal/service/mixed_native_dispatch_test.go b/backend/internal/service/mixed_native_dispatch_test.go deleted file mode 100644 index 11e9eec..0000000 --- a/backend/internal/service/mixed_native_dispatch_test.go +++ /dev/null @@ -1,215 +0,0 @@ -//go:build unit - -package service - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -func mixedPlainClientBody(protocol string, stream bool) []byte { - fields := `"messages":[{"role":"user","content":"hello"}]` - if protocol == APIProtocolResponses { - fields = `"input":"hello"` - } else if protocol == APIProtocolAnthropic { - fields = `"max_tokens":128,` + fields - } - return []byte(fmt.Sprintf(`{"model":"alias","stream":%t,%s}`, stream, fields)) -} - -func TestMixedResponsesMatchesExistingOpenAINativeDeepSeek(t *testing.T) { - for _, stream := range []bool{false, true} { - for _, history := range []bool{false, true} { - t.Run(fmt.Sprintf("stream=%t/history=%t", stream, history), func(t *testing.T) { - body := mixedPlainClientBody(APIProtocolResponses, stream) - if history { - body = []byte(fmt.Sprintf(`{"model":"alias","stream":%t,"input":[{"type":"function_call","call_id":"prior","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"prior","output":"first-result"},{"type":"function_call","call_id":"prior","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"prior","output":"later-result"}],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}}]}`, stream)) - } - body = []byte(strings.TrimSuffix(string(body), "}") + `,"max_output_tokens":128}`) - var upstreams []*httpUpstreamRecorder - for _, platform := range []string{PlatformOpenAI, PlatformMixed} { - account := mixedTestAccount() - account.Platform = platform - account.Extra["openai_passthrough"] = false - account.Credentials["model_mapping"] = map[string]any{"alias": "deepseek-v4-pro-0813", "deepseek-v4-pro-0813": "wrong-double-mapping"} - wire := `{"id":"resp_1","object":"response","model":"deepseek-v4-pro-0813","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"OK"}]}],"usage":{"input_tokens":9,"output_tokens":4}}` - if stream { - wire = strings.ReplaceAll(mixedResponsesTextSSE(), `"actual"`, `"deepseek-v4-pro-0813"`) - } - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(200, wire, stream)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - _, _, _, err := mixedForward(t, svc, account, APIProtocolResponses, body) - require.NoError(t, err) - require.Len(t, upstream.requests, 1) - require.Equal(t, "/v1/responses", upstream.lastReq.URL.Path) - require.Equal(t, "deepseek-v4-pro-0813", gjson.GetBytes(upstream.lastBody, "model").String()) - require.Equal(t, int64(128), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int(), "native Responses must preserve the caller's output limit") - if history { - require.Len(t, gjson.GetBytes(upstream.lastBody, "input").Array(), 2) - require.Contains(t, string(upstream.lastBody), "first-result") - require.NotContains(t, string(upstream.lastBody), "later-result") - } - upstreams = append(upstreams, upstream) - } - require.Equal(t, upstreams[0].lastReq.URL.String(), upstreams[1].lastReq.URL.String()) - require.JSONEq(t, string(upstreams[0].lastBody), string(upstreams[1].lastBody)) - }) - } - } -} - -func TestMixedMessagesMatchesNativeHelperWithoutCrossProtocolPermission(t *testing.T) { - for _, stream := range []bool{false, true} { - for _, history := range []bool{false, true} { - t.Run(fmt.Sprintf("stream=%t/history=%t", stream, history), func(t *testing.T) { - messages := `[{"role":"user","content":"hello"}]` - if history { - messages = `[{"role":"assistant","content":[{"type":"thinking","thinking":"check","signature":"sig"},{"type":"tool_use","id":"prior","name":"lookup","input":{"id":1}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"prior","content":"result"}]}]` - } - body := []byte(fmt.Sprintf(`{"model":"alias","stream":%t,"max_tokens":2048,"system":[{"type":"text","text":"Keep native system","cache_control":{"type":"ephemeral"}}],"thinking":{"type":"enabled","budget_tokens":1024},"messages":%s,"tools":[{"name":"lookup","description":"find an id","input_schema":{"type":"object","properties":{"id":{"type":"integer"}},"required":["id"]}}],"tool_choice":{"type":"auto"}}`, stream, messages)) - var upstreams []*httpUpstreamRecorder - for _, directHelper := range []bool{true, false} { - account := mixedTestAccount() - account.Credentials["model_mapping"] = map[string]any{"alias": "qwen3.8-27b", "qwen3.8-27b": "wrong-double-mapping"} - before, err := json.Marshal(account) - require.NoError(t, err) - wire := `{"id":"msg_1","type":"message","role":"assistant","model":"qwen3.8-27b","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}` - if stream { - wire = strings.ReplaceAll(toolAnthropicSSEStream(), `"actual"`, `"qwen3.8-27b"`) - } - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(200, wire, stream)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) - c.Set("api_key", &APIKey{Group: &Group{Platform: PlatformComposite, CrossProtocolConversionEnabled: false}}) - require.False(t, crossProtocolConversionAllowedFromContext(c)) - if directHelper { - _, err = svc.forwardAnthropicViaNativeAnthropicEndpoint(c.Request.Context(), c, account, body, "") - } else { - _, err = svc.ForwardAsAnthropic(c.Request.Context(), c, account, body, "", "") - } - require.NoError(t, err) - require.Len(t, upstream.requests, 1) - require.Equal(t, "http://upstream.example/v1/messages", upstream.lastReq.URL.String()) - require.Equal(t, "qwen3.8-27b", gjson.GetBytes(upstream.lastBody, "model").String()) - for _, field := range []string{"system", "thinking", "tools", "tool_choice", "messages"} { - require.JSONEq(t, gjson.GetBytes(body, field).Raw, gjson.GetBytes(upstream.lastBody, field).Raw, field) - } - require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) - after, err := json.Marshal(account) - require.NoError(t, err) - require.JSONEq(t, string(before), string(after)) - upstreams = append(upstreams, upstream) - } - require.JSONEq(t, string(upstreams[0].lastBody), string(upstreams[1].lastBody)) - }) - } - } -} - -func TestMixedResponsesShapedChatPreservesNativeProtocolAndToolsHTTP(t *testing.T) { - for _, target := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, stream := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/stream=%t", target, stream), func(t *testing.T) { - type capturedRequest struct { - path, authorization, apiKey string - body []byte - } - requests := make(chan capturedRequest, 3) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - requests <- capturedRequest{r.URL.Path, r.Header.Get("Authorization"), r.Header.Get("X-Api-Key"), body} - protocol := APIProtocolResponses - switch r.URL.Path { - case "/v1/chat/completions": - protocol = APIProtocolChatCompletions - case "/v1/messages": - protocol = APIProtocolAnthropic - } - if protocol != target { - refusal := mixedRefusal(protocol) - defer refusal.Body.Close() - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(refusal.StatusCode) - _, _ = io.Copy(w, refusal.Body) - return - } - if protocol != APIProtocolResponses && len(gjson.GetBytes(body, "messages").Array()) == 0 { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - _, _ = io.WriteString(w, `{"error":{"message":"messages is required","param":"messages"}}`) - return - } - upstreamStream := gjson.GetBytes(body, "stream").Bool() - wire, contentType := mixedResponsesToolSSE(), "text/event-stream" - if protocol == APIProtocolResponses && !upstreamStream { - wire, contentType = `{"id":"resp_1","object":"response","model":"actual","status":"completed","output":[{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{\"id\":1}"}],"usage":{"input_tokens":9,"output_tokens":4}}`, "application/json" - } - if protocol == APIProtocolChatCompletions { - wire = mixedChatToolSSE() - if !upstreamStream { - wire, contentType = mixedChatToolJSON, "application/json" - } - } else if protocol == APIProtocolAnthropic { - wire = toolAnthropicSSEStream() - } - w.Header().Set("Content-Type", contentType) - _, _ = io.WriteString(w, wire) - })) - defer server.Close() - account := mixedTestAccount() - account.Credentials["base_url"] = server.URL + "/v1" - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: headerCancelUpstreamFunc(server.Client().Do)} - body := mixedClientBody(APIProtocolResponses, stream) - result, rec, _, err := mixedForward(t, svc, account, APIProtocolChatCompletions, body) - require.NoError(t, err) - require.NotNil(t, result) - require.Equal(t, "actual", result.UpstreamModel) - require.Contains(t, rec.Body.String(), "lookup") - if stream { - require.Contains(t, rec.Body.String(), `"object":"chat.completion.chunk"`) - require.Contains(t, rec.Body.String(), "data: [DONE]") - require.NotContains(t, rec.Body.String(), "event: response.") - } else { - require.Equal(t, "chat.completion", gjson.Get(rec.Body.String(), "object").String()) - require.Equal(t, "lookup", gjson.Get(rec.Body.String(), "choices.0.message.tool_calls.0.function.name").String()) - } - require.Len(t, requests, mixedExpectedAttempts(APIProtocolResponses, target)) - for i := 0; len(requests) > 0; i++ { - req := <-requests - require.Equal(t, protocolUpstreamEndpoint(mixedExpectedProtocolOrder(APIProtocolResponses)[i]), req.path) - if req.path == "/v1/messages" { - require.Equal(t, "test-secret", req.apiKey) - require.Empty(t, req.authorization) - } else { - require.Equal(t, "Bearer test-secret", req.authorization) - require.Empty(t, req.apiKey) - } - require.Equal(t, "actual", gjson.GetBytes(req.body, "model").String()) - require.Contains(t, string(req.body), "prior") - require.Contains(t, string(req.body), "lookup") - if req.path == "/v1/responses" { - require.JSONEq(t, gjson.GetBytes(body, "input").Raw, gjson.GetBytes(req.body, "input").Raw) - } else { - require.NotEmpty(t, gjson.GetBytes(req.body, "messages").Array()) - require.False(t, gjson.GetBytes(req.body, "input").Exists()) - } - } - }) - } - } -} diff --git a/backend/internal/service/mixed_official_provider.go b/backend/internal/service/mixed_official_provider.go deleted file mode 100644 index b6fba12..0000000 --- a/backend/internal/service/mixed_official_provider.go +++ /dev/null @@ -1,128 +0,0 @@ -package service - -import ( - "net/url" - "strings" -) - -// These are also existing presets in the original MiniMax import form. -const mixedMiniMaxInternationalBaseURL = "https://api.minimax.io/v1" -const mixedMiniMaxInternationalAnthropicBaseURL = "https://api.minimax.io/anthropic" - -// The import entry does not define a new provider. Recognized first-party -// endpoints reuse the existing provider's protocol/URL helpers while the real -// account stays Mixed for key scope, scheduling, metering and error handling. -// A relay is never identified by its model name or a hostname substring. -func mixedOfficialProviderView(account *Account) *Account { - if !account.IsMixed() || account.Type != AccountTypeAPIKey { - return nil - } - configured := strings.TrimSpace(account.GetCredential("base_url")) - base, err := url.Parse(normalizeMixedBaseURL(configured)) - if err != nil || !strings.EqualFold(base.Scheme, "https") || base.User != nil || - base.RawQuery != "" || base.ForceQuery || base.Fragment != "" || - (base.Port() != "" && base.Port() != "443") || base.RawPath != "" { - return nil - } - path := strings.TrimSuffix(strings.TrimRight(base.Path, "/"), "/v1") - for _, preset := range []struct{ platform, mode, base string }{ - {PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL}, - {PlatformDeepseek, AccountModePayG, DefaultDeepseekAnthropicBaseURL}, - {PlatformKimi, AccountModePayG, DefaultKimiPayGBaseURL}, - {PlatformKimi, AccountModePayG, DefaultKimiPayGAnthropicBaseURL}, - {PlatformKimi, AccountModeCoding, DefaultKimiCodingBaseURL}, - {PlatformKimi, AccountModeCoding, DefaultKimiCodingAnthropicBaseURL}, - {PlatformZhipu, AccountModePayG, DefaultZhipuPayGBaseURL}, - {PlatformZhipu, AccountModeCoding, DefaultZhipuCodingBaseURL}, - {PlatformZhipu, AccountModePayG, DefaultZhipuAnthropicBaseURL}, - {PlatformMiniMax, AccountModePayG, DefaultMiniMaxBaseURL}, - {PlatformMiniMax, AccountModePayG, DefaultMiniMaxAnthropicBaseURL}, - {PlatformMiniMax, AccountModePayG, mixedMiniMaxInternationalBaseURL}, - {PlatformMiniMax, AccountModePayG, mixedMiniMaxInternationalAnthropicBaseURL}, - } { - target, _ := url.Parse(preset.base) - presetPath := strings.TrimSuffix(strings.TrimRight(target.Path, "/"), "/v1") - if !strings.EqualFold(base.Hostname(), target.Hostname()) || path != presetPath { - continue - } - view := cloneSnapshotAccount(account) - view.Platform = preset.platform - if view.GetCredential("account_mode") == "" { - view.Credentials["account_mode"] = preset.mode - } - if view.GetCredential("api_protocol") == "" { - view.Credentials["api_protocol"] = APIProtocolAdaptive - } - if view.IsAdaptiveAPIProtocol() { - bases, _ := view.Credentials["api_base_urls"].(map[string]any) - if bases == nil { - bases = make(map[string]any, 3) - } - for _, protocol := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - if value, _ := bases[protocol].(string); strings.TrimSpace(value) == "" { - bases[protocol] = mixedOfficialDefaultURL(account, view.defaultCNProtocolBaseURL(protocol)) - } - } - view.Credentials["api_base_urls"] = bases - } - // Existing native helpers supply provider-specific paths, never a new - // host. Reject native classification if stored protocol URLs would move - // this Mixed credential to another origin; generic routing still enforces - // the same-origin rule for explicitly configured relay endpoints. - for _, protocol := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - values := []string{mixedOfficialDefaultURL(account, view.GetCNProtocolBaseURL(protocol))} - if bases, ok := view.Credentials["api_base_urls"].(map[string]any); ok { - if explicit, _ := bases[protocol].(string); strings.TrimSpace(explicit) != "" { - values = append(values, explicit) - } - } - for _, value := range values { - candidate, err := url.Parse(value) - if err != nil || candidate.User != nil || candidate.RawQuery != "" || candidate.ForceQuery || candidate.Fragment != "" || - !strings.EqualFold(candidate.Scheme, base.Scheme) || !sameMixedOfficialOrigin(base, candidate) { - return nil - } - } - } - return &view - } - return nil -} - -// MiniMax's original form has domestic and international presets, whereas its -// backend default constants are domestic. Derive paths from those same defaults -// while retaining the explicitly selected international credential origin. -func mixedOfficialDefaultURL(account *Account, value string) string { - base, err := url.Parse(account.GetCredential("base_url")) - if err != nil || !strings.EqualFold(base.Hostname(), "api.minimax.io") { - return value - } - if value != DefaultMiniMaxBaseURL && value != DefaultMiniMaxAnthropicBaseURL { - return value - } - target, _ := url.Parse(value) - target.Host = base.Host - return target.String() -} - -func sameMixedOfficialOrigin(a, b *url.URL) bool { - port := func(u *url.URL) string { - if p := u.Port(); p != "" { - return p - } - return "443" - } - return strings.EqualFold(a.Hostname(), b.Hostname()) && port(a) == port(b) -} - -// Only protocol details use the underlying first-party platform. Never use -// this as a replacement account platform for billing or group permissions. -func (a *Account) openAIWirePlatform() string { - if a == nil { - return "" - } - if native := mixedOfficialProviderView(a); native != nil { - return native.Platform - } - return a.Platform -} diff --git a/backend/internal/service/mixed_official_provider_test.go b/backend/internal/service/mixed_official_provider_test.go deleted file mode 100644 index ce273bf..0000000 --- a/backend/internal/service/mixed_official_provider_test.go +++ /dev/null @@ -1,251 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -func TestMixedOfficialImportsReuseOriginalProviderForwarding(t *testing.T) { - for _, provider := range []struct{ platform, base, model, responsesPath string }{ - {PlatformDeepseek, DefaultDeepseekBaseURL, "deepseek-v4-pro-0813", "/responses"}, - {PlatformKimi, DefaultKimiPayGBaseURL, "kimi-k3", "/v1/responses"}, - {PlatformZhipu, DefaultZhipuPayGBaseURL, "glm-5.3", ""}, - {PlatformMiniMax, DefaultMiniMaxBaseURL, "MiniMax-M3", "/v1/responses"}, - {PlatformKimi, DefaultKimiCodingBaseURL, "kimi-k3", "/coding/v1/responses"}, - {PlatformZhipu, DefaultZhipuCodingBaseURL, "glm-5.3", ""}, - {PlatformMiniMax, mixedMiniMaxInternationalBaseURL, "MiniMax-M3", "/v1/responses"}, - } { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, stream := range []bool{false, true} { - for _, history := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/%s/stream=%t/history=%t", provider.platform, ingress, stream, history), func(t *testing.T) { - body := mixedPlainClientBody(ingress, stream) - if history { - body = mixedClientBody(ingress, stream) - } - if ingress == APIProtocolResponses { - body = []byte(strings.TrimSuffix(string(body), "}") + `,"store":true,"previous_response_id":"old-response","max_output_tokens":128}`) - } - var upstreams []*httpUpstreamRecorder - for _, platform := range []string{provider.platform, PlatformMixed} { - account := mixedTestAccount() - account.Platform = platform - account.Credentials["base_url"] = provider.base - account.Credentials["model_mapping"] = map[string]any{"alias": provider.model} - account.Extra["openai_passthrough"] = false - if platform != PlatformMixed { - account.Credentials["api_protocol"] = APIProtocolAdaptive - if provider.base == DefaultKimiCodingBaseURL || provider.base == DefaultZhipuCodingBaseURL { - account.Credentials["account_mode"] = AccountModeCoding - } - if provider.base == mixedMiniMaxInternationalBaseURL { - account.Credentials["api_base_urls"] = map[string]any{ - APIProtocolChatCompletions: provider.base, - APIProtocolResponses: provider.base, - APIProtocolAnthropic: mixedMiniMaxInternationalAnthropicBaseURL, - } - } - } - before, err := json.Marshal(account) - require.NoError(t, err) - wire := mixedChatToolJSON - if stream { - wire = mixedChatToolSSE() - } - if ingress == APIProtocolResponses && provider.responsesPath != "" { - wire = `{"id":"resp_1","object":"response","model":"actual","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"OK"}]}],"usage":{"input_tokens":9,"output_tokens":4}}` - if stream { - wire = mixedResponsesTextSSE() - } - } else if ingress == APIProtocolAnthropic { - wire = `{"id":"msg_1","type":"message","role":"assistant","model":"actual","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","usage":{"input_tokens":9,"output_tokens":4}}` - if stream { - wire = toolAnthropicSSEStream() - } - } - wire = strings.ReplaceAll(wire, `"actual"`, `"`+provider.model+`"`) - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(200, wire, stream)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, _, _, err := mixedForward(t, svc, account, ingress, body) - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, upstream.requests, 1) - require.Equal(t, provider.model, result.UpstreamModel) - if ingress == APIProtocolResponses && provider.responsesPath != "" { - require.Equal(t, provider.responsesPath, upstream.lastReq.URL.Path) - require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool()) - require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists()) - } - after, err := json.Marshal(account) - require.NoError(t, err) - require.JSONEq(t, string(before), string(after), "request-only provider details must not mutate the imported account") - upstreams = append(upstreams, upstream) - } - require.Equal(t, upstreams[0].lastReq.URL.String(), upstreams[1].lastReq.URL.String()) - require.JSONEq(t, string(upstreams[0].lastBody), string(upstreams[1].lastBody)) - require.Equal(t, upstreams[0].lastReq.Header.Get("Authorization"), upstreams[1].lastReq.Header.Get("Authorization")) - require.Equal(t, upstreams[0].lastReq.Header.Get("X-Api-Key"), upstreams[1].lastReq.Header.Get("X-Api-Key")) - }) - } - } - } - } -} - -func TestMixedOfficialProviderRecognitionDoesNotGuessFromModels(t *testing.T) { - for _, base := range []string{ - "https://relay.example/v1", "https://api.deepseek.com.evil.example", "https://deepseek.example", - "https://api.deepseek.com:8443", "http://api.deepseek.com", "https://api.deepseek.com/custom", - "https://api.deepseek.com/%61nthropic", "https://api.deepseek.com?key=value", "https://secret@api.deepseek.com", - "https://api.minimax.io.evil.example/v1", "https://api.minimax.io:8443/v1", - } { - t.Run(base, func(t *testing.T) { - account := mixedTestAccount() - account.Credentials["base_url"] = base - account.Credentials["model_mapping"] = map[string]any{"deepseek-v4-pro-0813": "deepseek-v4-pro-0813"} - require.Nil(t, mixedOfficialProviderView(account)) - require.Equal(t, PlatformMixed, account.openAIWirePlatform()) - }) - } - account := mixedTestAccount() - account.Credentials["base_url"] = DefaultDeepseekBaseURL - account.Credentials["api_base_urls"] = map[string]any{APIProtocolAnthropic: "https://other.example/anthropic"} - require.Nil(t, mixedOfficialProviderView(account), "do not reuse vendor defaults across credential origins") -} - -func TestMixedOfficialInternationalExplicitAnthropicPreservesOrigin(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, stream := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/stream=%t", ingress, stream), func(t *testing.T) { - var upstreams []*httpUpstreamRecorder - for _, platform := range []string{PlatformMiniMax, PlatformMixed} { - account := mixedTestAccount() - account.Platform = platform - account.Credentials["base_url"] = mixedMiniMaxInternationalAnthropicBaseURL - account.Credentials["api_protocol"] = APIProtocolAnthropic - account.Credentials["model_mapping"] = map[string]any{"alias": "MiniMax-M3"} - wire := `{"id":"msg_1","type":"message","role":"assistant","model":"MiniMax-M3","content":[{"type":"text","text":"OK"}],"stop_reason":"end_turn","usage":{"input_tokens":9,"output_tokens":4}}` - // Existing Responses/Chat -> Anthropic forwarding always reads - // upstream SSE, buffering it when the client is non-streaming. - upstreamStream := stream || ingress != APIProtocolAnthropic - if upstreamStream { - wire = toolAnthropicSSEStream() - } - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(200, wire, upstreamStream)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - _, _, _, err := mixedForward(t, svc, account, ingress, mixedClientBody(ingress, stream)) - require.NoError(t, err) - require.Len(t, upstream.requests, 1) - require.Equal(t, "https://api.minimax.io/anthropic/v1/messages", upstream.lastReq.URL.String()) - if platform == PlatformMixed { - require.Equal(t, mixedMiniMaxInternationalBaseURL, account.GetOpenAIFormatBaseURL()) - } - upstreams = append(upstreams, upstream) - } - require.JSONEq(t, string(upstreams[0].lastBody), string(upstreams[1].lastBody)) - }) - } - } -} - -func TestMixedOfficialCanceledRequestDoesNotReachUpstream(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - t.Run(ingress, func(t *testing.T) { - account := mixedTestAccount() - account.Credentials["base_url"] = DefaultDeepseekBaseURL - account.Credentials["model_mapping"] = map[string]any{"alias": "deepseek-v4-pro-0813"} - ctx, cancel := context.WithCancel(context.Background()) - cancel() - upstream := headerCancelUpstreamFunc(func(req *http.Request) (*http.Response, error) { - require.ErrorIs(t, req.Context().Err(), context.Canceled) - return nil, req.Context().Err() - }) - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - body := mixedPlainClientBody(ingress, true) - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest(http.MethodPost, protocolUpstreamEndpoint(ingress), strings.NewReader(string(body))).WithContext(ctx) - c.Set("api_key", &APIKey{Group: &Group{Platform: PlatformMixed}}) - done := make(chan error, 1) - go func() { - var err error - switch ingress { - case APIProtocolResponses: - _, err = svc.Forward(context.WithoutCancel(ctx), c, account, body) - case APIProtocolChatCompletions: - _, err = svc.ForwardAsChatCompletions(context.WithoutCancel(ctx), c, account, body, "", "") - case APIProtocolAnthropic: - _, err = svc.ForwardAsAnthropic(context.WithoutCancel(ctx), c, account, body, "", "") - } - done <- err - }() - select { - case err := <-done: - require.ErrorIs(t, err, context.Canceled) - case <-time.After(2 * time.Second): - t.Fatal("official dispatch did not honor client cancellation") - } - }) - } -} - -func TestMixedOfficialProtocolHelpersReuseProviderModes(t *testing.T) { - for _, provider := range []struct{ platform, mode, base string }{ - {PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL}, - {PlatformDeepseek, AccountModePayG, DefaultDeepseekAnthropicBaseURL}, - {PlatformKimi, AccountModePayG, DefaultKimiPayGBaseURL}, - {PlatformKimi, AccountModePayG, DefaultKimiPayGAnthropicBaseURL}, - {PlatformKimi, AccountModeCoding, DefaultKimiCodingBaseURL}, - {PlatformKimi, AccountModeCoding, DefaultKimiCodingAnthropicBaseURL}, - {PlatformZhipu, AccountModePayG, DefaultZhipuPayGBaseURL}, - {PlatformZhipu, AccountModeCoding, DefaultZhipuCodingBaseURL}, - {PlatformZhipu, AccountModePayG, DefaultZhipuAnthropicBaseURL}, - {PlatformMiniMax, AccountModePayG, DefaultMiniMaxBaseURL}, - {PlatformMiniMax, AccountModePayG, DefaultMiniMaxAnthropicBaseURL}, - } { - for _, protocol := range []string{APIProtocolAdaptive, APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - t.Run(provider.base+"/"+protocol, func(t *testing.T) { - native := mixedTestAccount() - native.Platform = provider.platform - native.Credentials["base_url"] = provider.base - native.Credentials["account_mode"] = provider.mode - native.Credentials["api_protocol"] = protocol - if protocol == APIProtocolAdaptive { - // The original import form saves one official base per wire - // protocol; a Mixed import derives the same existing defaults. - bases := make(map[string]any, 3) - for _, wire := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - bases[wire] = native.defaultCNProtocolBaseURL(wire) - } - native.Credentials["api_base_urls"] = bases - } - mixed := cloneSnapshotAccount(native) - mixed.Platform = PlatformMixed - delete(mixed.Credentials, "account_mode") - delete(mixed.Credentials, "api_base_urls") - require.NotNil(t, mixedOfficialProviderView(&mixed)) - require.Equal(t, native.GetAPIProtocol(), mixed.GetAPIProtocol()) - require.Equal(t, native.GetOpenAIBaseURL(), mixed.GetOpenAIBaseURL()) - require.Equal(t, native.GetAnthropicProtocolBaseURL(), mixed.GetAnthropicProtocolBaseURL()) - require.Equal(t, native.GetOpenAIFormatBaseURL(), mixed.GetOpenAIFormatBaseURL()) - for _, wire := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - require.Equal(t, native.GetCNProtocolBaseURL(wire), mixed.GetCNProtocolBaseURL(wire)) - } - body := []byte(`{"model":"alias"}`) - require.Equal(t, deepSeekDuplicateCallIDRewrite(native, body), deepSeekDuplicateCallIDRewrite(&mixed, body)) - require.Equal(t, PlatformMixed, mixed.Platform) - }) - } - } -} diff --git a/backend/internal/service/mixed_provider_inflight_plaza_test.go b/backend/internal/service/mixed_provider_inflight_plaza_test.go deleted file mode 100644 index 12693b9..0000000 --- a/backend/internal/service/mixed_provider_inflight_plaza_test.go +++ /dev/null @@ -1,224 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestMixedVendorInflightCandidateRates(t *testing.T) { - billing := newGatewayRecordUsageServiceForTest(&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}).billingService - billing.fallbackPrices = mixedRateTestPricing() - id := int64(2) - key := &APIKey{User: &User{ID: 1}, GroupID: &id, Group: &Group{ID: id, Platform: PlatformMixed, RateMultiplier: 9, MixedProviderRateMultipliers: map[string]float64{"kimi": 0.2, "zhipu": 0.8}}} - r := newUserGroupRateResolver(&userGroupRateRepoHotpathStub{}, nil, time.Minute, nil, "test") - d := inflightEstimateDeps{billing: billing, userGroupRate: r.Resolve} - for model, rate := range map[string]float64{"kimi-k2": 0.2, "glm-5": 0.8} { - request := InflightEstimateRequest{Model: model, BodyBytes: 400, MaxTokens: 50} - got, priced := d.estimate(context.Background(), key, request) - require.True(t, priced) - price, err := billing.GetModelPricing(model) - require.NoError(t, err) - require.InDelta(t, rate*(100*price.InputPricePerToken+50*price.OutputPricePerToken), got, 1e-12) - } - key.Group.MixedProviderRateMultipliers["kimi"] = 0 - d.accountMappedModels = func(context.Context, *APIKey, string) []string { return []string{"glm-5"} } - got, priced := d.estimate(context.Background(), key, InflightEstimateRequest{Model: "kimi-k2", BodyBytes: 400, MaxTokens: 50}) - require.True(t, priced) - price, err := billing.GetModelPricing("glm-5") - require.NoError(t, err) - require.InDelta(t, 0.8*(100*price.InputPricePerToken+50*price.OutputPricePerToken), got, 1e-12, "free public alias must not bypass paid mapped model reservation") - zero := 0.0 - d.userGroupRate = newUserGroupRateResolver(&userGroupRateRepoHotpathStub{rate: &zero}, nil, time.Minute, nil, "test").Resolve - got, priced = d.estimate(context.Background(), key, InflightEstimateRequest{Model: "kimi-k2", BodyBytes: 400, MaxTokens: 50}) - require.True(t, priced) - require.Zero(t, got) - // Even an unpriced alias requires no reservation when a user override - // makes every possible mapped vendor free. - got, priced = d.estimate(context.Background(), key, InflightEstimateRequest{Model: "qa-unpriced-alias", BodyBytes: 400, MaxTokens: 50}) - require.True(t, priced) - require.Zero(t, got) - // A paid independent image rate must not make a free token request unpriced. - key.Group.ImageRateIndependent = true - key.Group.ImageRateMultiplier = 2 - d.userGroupRate = r.Resolve - d.accountMappedModels = nil - got, priced = d.estimate(context.Background(), key, InflightEstimateRequest{Model: "kimi-k2", BodyBytes: 400, MaxTokens: 50}) - require.True(t, priced) - require.Zero(t, got) - // Explicit requested billing must not reserve a paid account-mapping fallback. - d.accountMappedModels = func(context.Context, *APIKey, string) []string { return []string{"glm-5"} } - d.resolveMapping = func(context.Context, int64, string) ChannelMappingResult { - return ChannelMappingResult{MappedModel: "glm-5", BillingModelSource: BillingModelSourceRequested} - } - got, priced = d.estimate(context.Background(), key, InflightEstimateRequest{Model: "kimi-k2", BodyBytes: 400, MaxTokens: 50}) - require.True(t, priced) - require.Zero(t, got) - // A real, priced channel mapping also fixes the charge identity. The - // public model and account target must not turn its zero rate into a hold. - d.resolveMapping = func(context.Context, int64, string) ChannelMappingResult { - return ChannelMappingResult{MappedModel: "kimi-k2", Mapped: true, BillingModelSource: BillingModelSourceChannelMapped} - } - got, priced = d.estimate(context.Background(), key, InflightEstimateRequest{Model: "glm-5", BodyBytes: 400, MaxTokens: 50}) - require.True(t, priced) - require.Zero(t, got) - // An unpriced channel identity still falls back to the concrete target. - d.resolveMapping = func(context.Context, int64, string) ChannelMappingResult { - return ChannelMappingResult{MappedModel: "qa-unpriced-alias", Mapped: true, BillingModelSource: BillingModelSourceChannelMapped} - } - got, priced = d.estimate(context.Background(), key, InflightEstimateRequest{Model: "qa-unpriced-request", BodyBytes: 400, MaxTokens: 50}) - require.True(t, priced) - require.Positive(t, got) -} - -func TestMixedVendorPlazaModelRate(t *testing.T) { - group := Group{ID: 2, Name: "Mixed", Platform: PlatformMixed, RateMultiplier: 9, MixedProviderRateMultipliers: map[string]float64{"kimi": 0.2, "zhipu": 0}} - svc := newPlazaService([]Channel{plazaPricedChannel(1, "shop", []int64{2}, PlatformMixed, "kimi-k2")}, []Group{group}, nil) - out, err := svc.ListGroups(context.Background()) - require.NoError(t, err) - require.Len(t, out, 1) - require.Len(t, out[0].Models, 1) - require.Equal(t, "kimi", out[0].Models[0].ModelVendor) - require.NotNil(t, out[0].Models[0].RateMultiplier) - require.Equal(t, 0.2, *out[0].Models[0].RateMultiplier) -} - -func TestMixedVendorPlazaMappedBillingIdentities(t *testing.T) { - group := Group{ID: 2, Name: "Mixed", Platform: PlatformMixed, RateMultiplier: 9, MixedProviderRateMultipliers: map[string]float64{"kimi": 0.2, "deepseek": 0.8, "zhipu": 0.4}} - repo := &modelsListAccountRepoStub{byGroup: map[int64][]Account{2: { - {Platform: PlatformMixed, Credentials: map[string]any{"model_mapping": map[string]any{"kimi-k2": "deepseek-v4-pro"}}}, - {Platform: PlatformMixed, Credentials: map[string]any{"model_mapping": map[string]any{"kimi-k2": "glm-5"}}}, - }}} - gateway := &GatewayService{accountRepo: repo} - svc := newPlazaService(nil, []Group{group}, nil) - svc.SetListedModelCatalog(plazaGatewayModelCatalog{gateway: gateway}) - out, err := svc.ListGroups(context.Background()) - require.NoError(t, err) - require.Len(t, out, 1) - require.Len(t, out[0].Models, 2) - for _, m := range out[0].Models { - require.Equal(t, "kimi-k2", m.Name, "public model identity must remain unchanged") - require.Contains(t, []string{"deepseek-v4-pro", "glm-5"}, m.BillingModel) - require.Equal(t, ModelVendor(m.BillingModel), m.ModelVendor) - require.Equal(t, group.RateMultiplierForModel(m.BillingModel), *m.RateMultiplier) - } -} - -func TestMixedVendorPlazaMappedPriceDoesNotInheritAliasCard(t *testing.T) { - billing := newGatewayRecordUsageServiceForTest(&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}).billingService - billing.fallbackPrices = mixedRateTestPricing() - group := Group{ID: 2, Platform: PlatformMixed, ModelPricing: []ChannelModelPricing{ - {Models: []string{"kimi-k2"}, BillingMode: BillingModeImage, PerRequestPrice: testPtrFloat64(10)}, - {Models: []string{"glm-5"}, BillingMode: BillingModePerRequest, PerRequestPrice: testPtrFloat64(3)}, - }} - repo := &modelsListAccountRepoStub{byGroup: map[int64][]Account{2: { - {Platform: PlatformMixed, Credentials: map[string]any{"model_mapping": map[string]any{"kimi-k2": "glm-5"}}}, - {Platform: PlatformMixed, Credentials: map[string]any{"model_mapping": map[string]any{"kimi-k2": "qa-unpriced-target"}}}, - }}} - svc := newPlazaService([]Channel{plazaPricedChannel(1, "alias", []int64{2}, PlatformMixed, "kimi-k2")}, []Group{group}, nil) - svc.billingService = billing - svc.resolver = NewModelPricingResolver(nil, billing) - svc.SetListedModelCatalog(plazaGatewayModelCatalog{gateway: &GatewayService{accountRepo: repo}}) - out, err := svc.ListGroups(context.Background()) - require.NoError(t, err) - require.Len(t, out, 1) - require.Len(t, out[0].Models, 2) - for _, m := range out[0].Models { - switch m.BillingModel { - case "glm-5": - require.NotNil(t, m.Pricing) - require.Equal(t, BillingModePerRequest, m.Pricing.BillingMode) - require.Equal(t, 3.0, *m.Pricing.PerRequestPrice) - case "qa-unpriced-target": - require.Nil(t, m.Pricing, "unknown candidate must not borrow the alias's image/token price") - default: - t.Fatalf("unexpected billing model %q", m.BillingModel) - } - } -} - -func TestMixedVendorInflightNormalizedMappedIdentity(t *testing.T) { - billing := newGatewayRecordUsageServiceForTest(&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}).billingService - id := int64(2) - key := &APIKey{User: &User{ID: 1}, GroupID: &id, Group: &Group{ID: id, Platform: PlatformMixed, MixedProviderRateMultipliers: map[string]float64{"other": 0, "deepseek": 0.8}}} - d := inflightEstimateDeps{billing: billing, resolver: NewModelPricingResolver(nil, billing), accountMappedModels: func(context.Context, *APIKey, string) []string { - return []string{"provider/deepseek-v4-flash"} - }} - got, priced := d.estimate(context.Background(), key, InflightEstimateRequest{Model: "qa-unpriced-alias", BodyBytes: 400, MaxTokens: 50}) - require.True(t, priced) - price, err := billing.GetModelPricing("deepseek-v4-flash") - require.NoError(t, err) - require.InDelta(t, 0.8*(100*price.InputPricePerToken+50*price.OutputPricePerToken), got, 1e-12) - require.Positive(t, got, "untrusted prefix cannot make a normalized paid model skip its hold") - d.resolveMapping = func(context.Context, int64, string) ChannelMappingResult { - return ChannelMappingResult{MappedModel: "provider/deepseek-v4-flash", BillingModelSource: BillingModelSourceRequested} - } - got, priced = d.estimate(context.Background(), key, InflightEstimateRequest{Model: "provider/deepseek-v4-flash", BodyBytes: 400, MaxTokens: 50}) - require.True(t, priced) - require.Zero(t, got, "priced requested identity retains its original vendor") - d.resolveMapping = func(context.Context, int64, string) ChannelMappingResult { - return ChannelMappingResult{MappedModel: "provider/deepseek-v4-flash", BillingModelSource: BillingModelSourceResponse} - } - got, priced = d.estimate(context.Background(), key, InflightEstimateRequest{Model: "qa-unpriced-alias", BodyBytes: 400, MaxTokens: 50}) - require.True(t, priced) - require.Zero(t, got, "response-model baseline keeps the original identity before a declaration") - d.resolveMapping = nil - key.Group.ModelPricing = []ChannelModelPricing{{Models: []string{"provider/deepseek-v4-flash"}, InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(2e-6)}} - got, priced = d.estimate(context.Background(), key, InflightEstimateRequest{Model: "qa-unpriced-alias", BodyBytes: 400, MaxTokens: 50}) - require.True(t, priced) - require.Zero(t, got, "explicit original-identity pricing must prevent normalization") -} - -func TestMixedVendorCatalogueResponseSourceRetainsBaselineIdentity(t *testing.T) { - billing := newGatewayRecordUsageServiceForTest(&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}).billingService - ch := Channel{ID: 1, Status: StatusActive, GroupIDs: []int64{2}, BillingModelSource: BillingModelSourceResponse} - channels := newTestChannelService(makeStandardRepo(ch, map[int64]string{2: PlatformMixed})) - accounts := &modelsListAccountRepoStub{byGroup: map[int64][]Account{2: { - {Platform: PlatformMixed, Credentials: map[string]any{"model_mapping": map[string]any{"qa-unpriced-alias": "provider/deepseek-v4-flash"}}}, - }}} - group := Group{ID: 2, Platform: PlatformMixed, MixedProviderRateMultipliers: map[string]float64{"other": 0, "deepseek": 0.8}} - gateway := &GatewayService{accountRepo: accounts, billingService: billing, channelService: channels, resolver: NewModelPricingResolver(channels, billing)} - cat := gateway.ensureGroupModelsCatalog(context.Background(), &group.ID) - require.Equal(t, []string{"provider/deepseek-v4-flash"}, gateway.mixedCatalogueBillingModels(context.Background(), &group, "qa-unpriced-alias", cat)) - gateway.SupportedKeyProviders(context.Background(), &group) - require.Equal(t, []string{"other"}, group.SupportedModelVendors) -} - -func TestMixedVendorCatalogueExplicitSourcePricingFallback(t *testing.T) { - for _, source := range []string{BillingModelSourceRequested, BillingModelSourceChannelMapped} { - t.Run(source, func(t *testing.T) { - billing := newGatewayRecordUsageServiceForTest(&openAIRecordUsageLogRepoStub{}, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}).billingService - ch := Channel{ID: 1, Name: "test", Status: StatusActive, GroupIDs: []int64{2}, BillingModelSource: source} - channels := newTestChannelService(makeStandardRepo(ch, map[int64]string{2: PlatformMixed})) - accounts := &modelsListAccountRepoStub{byGroup: map[int64][]Account{2: { - {Platform: PlatformMixed, Credentials: map[string]any{"model_mapping": map[string]any{"qa-unpriced-alias": "glm-5"}}}, - }}} - group := Group{ID: 2, Platform: PlatformMixed, MixedProviderRateMultipliers: map[string]float64{"other": 0, "zhipu": 0.4}} - gateway := &GatewayService{accountRepo: accounts, billingService: billing, channelService: channels, resolver: NewModelPricingResolver(channels, billing)} - gateway.SupportedKeyProviders(context.Background(), &group) - require.Equal(t, []string{"zhipu"}, group.SupportedModelVendors) - svc := newPlazaService(nil, []Group{group}, nil) - svc.billingService, svc.resolver = billing, gateway.resolver - svc.SetListedModelCatalog(plazaGatewayModelCatalog{gateway: gateway}) - out, err := svc.ListGroups(context.Background()) - require.NoError(t, err) - require.Len(t, out, 1) - require.Len(t, out[0].Models, 1) - require.Equal(t, "glm-5", out[0].Models[0].BillingModel) - require.Equal(t, 0.4, *out[0].Models[0].RateMultiplier) - // A requested price fixes the identity; unchanged channel_mapped - // must still use the concrete account identity on both protocols. - group.ModelPricing = []ChannelModelPricing{{Models: []string{"qa-unpriced-alias"}, InputPrice: testPtrFloat64(0)}} - cat := gateway.ensureGroupModelsCatalog(context.Background(), &group.ID) - expected := []string{"glm-5"} - if source == BillingModelSourceRequested { - expected = []string{"qa-unpriced-alias"} - } - require.Equal(t, expected, gateway.mixedCatalogueBillingModels(context.Background(), &group, "qa-unpriced-alias", cat)) - }) - } -} diff --git a/backend/internal/service/mixed_provider_protocol_regression_test.go b/backend/internal/service/mixed_provider_protocol_regression_test.go deleted file mode 100644 index 55f8a86..0000000 --- a/backend/internal/service/mixed_provider_protocol_regression_test.go +++ /dev/null @@ -1,291 +0,0 @@ -//go:build unit - -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -func TestMixedProtocolRefusalAfterHeartbeat(t *testing.T) { - for _, protocol := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - for _, boundary := range []string{"heartbeat", "output", "committed", "cancelled", "write failed", "headers only"} { - t.Run(protocol+"/"+boundary, func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - state := &openAIProtocolFallbackState{clientCtx: ctx, visited: map[string]bool{}, order: []string{APIProtocolResponses, APIProtocolAnthropic, APIProtocolChatCompletions}} - ctx = context.WithValue(ctx, openAIProtocolFallbackKey{}, state) - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest(http.MethodPost, protocolUpstreamEndpoint(protocol), nil).WithContext(ctx) - if boundary == "headers only" { - c.Writer.WriteHeaderNow() - } else { - stop := StartOpenAIStreamSSEKeepalive(c, time.Hour) - defer stop() - value, _ := c.Get(openAICompactSSEKeepaliveKey) - keepalive := value.(*openAICompactSSEKeepalive) - require.True(t, keepalive.beat()) - require.True(t, c.Writer.Written()) - require.False(t, openAIStreamClientOutputStarted(c, false)) - switch boundary { - case "output": - _, err := c.Writer.Write([]byte("data: real output\n\n")) - require.NoError(t, err) - case "committed": - MarkResponseCommitted(c) - case "cancelled": - cancel() - case "write failed": - keepalive.mu.Lock() - keepalive.writeFailed = true - keepalive.mu.Unlock() - } - } - response := mixedRefusal(protocol) - var errorBody bytes.Buffer - _, err := errorBody.ReadFrom(response.Body) - require.NoError(t, err) - _ = response.Body.Close() - err = (&OpenAIGatewayService{}).protocolRefusalFallback(ctx, c, mixedTestAccount(), protocol, response.StatusCode, errorBody.Bytes(), false) - if boundary == "heartbeat" { - var refusal *openAIProtocolRefusal - require.ErrorAs(t, err, &refusal) - require.NotEqual(t, protocol, refusal.next) - } else { - require.NoError(t, err, "real output, committed errors and failed/cancelled clients must not replay") - } - }) - } - } -} - -func TestMixedGLMParameterAdaptationAcrossIngress(t *testing.T) { - for _, upstreamModel := range []string{"glm-5.3", "z-ai/glm-5.3", "zhipuai/glm-5.3"} { - for _, protocol := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - for _, effort := range []string{"", "medium", "xhigh", "low"} { - t.Run(upstreamModel+"/"+protocol+"/"+effort, func(t *testing.T) { - account := mixedTestAccount() - account.Credentials["model_mapping"] = map[string]any{"glm-client": upstreamModel} - body := map[string]any{"model": "glm-client", "stream": false} - switch protocol { - case APIProtocolResponses: - body["input"] = "hello" - if effort != "" { - body["reasoning"] = map[string]string{"effort": effort} - } - case APIProtocolAnthropic: - body["max_tokens"] = 1024 - body["messages"] = []map[string]string{{"role": "user", "content": "hello"}} - if effort != "" { - body["output_config"] = map[string]string{"effort": effort} - } - default: - body["messages"] = []map[string]string{{"role": "user", "content": "hello"}} - if effort != "" { - body["reasoning_effort"] = effort - } - } - encoded, err := json.Marshal(body) - require.NoError(t, err) - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(protocol, APIProtocolChatCompletions, mixedHTTPResponse(200, strings.ReplaceAll(mixedChatToolJSON, `"actual"`, `"`+upstreamModel+`"`), false))} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, _, _, err := mixedForward(t, svc, account, protocol, encoded) - require.NoError(t, err) - require.Equal(t, upstreamModel, result.UpstreamModel) - require.Equal(t, upstreamModel, gjson.GetBytes(upstream.lastBody, "model").String()) - got := gjson.GetBytes(upstream.lastBody, "reasoning_effort") - if effort == "" { - if protocol == APIProtocolAnthropic { - // Preserve the existing Anthropic bridge's default medium, - // then apply the same GLM adaptation as explicit medium. - require.Equal(t, "high", got.String()) - } else { - require.False(t, got.Exists()) - } - return - } - want := map[string]string{"medium": "high", "xhigh": "max", "low": "low"}[effort] - require.Equal(t, want, got.String()) - }) - } - } - } -} - -func TestMixedDispatcherFallbackAfterHeartbeat(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, ingress := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - for _, outcome := range []string{APIProtocolResponses, APIProtocolAnthropic, "refused"} { - t.Run(ingress+"/"+outcome, func(t *testing.T) { - order := mixedExpectedProtocolOrder(ingress) - responses := []*http.Response{} - for _, protocol := range order { - if protocol != outcome { - responses = append(responses, mixedRefusal(protocol)) - continue - } - wire := mixedResponsesToolSSE() - if protocol == APIProtocolAnthropic { - wire = toolAnthropicSSEStream() - } - responses = append(responses, mixedHTTPResponse(200, wire, true)) - break - } - upstream := &httpUpstreamRecorder{responses: responses} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - body := mixedClientBody(ingress, true) - rec := httptest.NewRecorder() - c, _ := gin.CreateTestContext(rec) - c.Request = httptest.NewRequest(http.MethodPost, protocolUpstreamEndpoint(ingress), bytes.NewReader(body)) - c.Set("api_key", &APIKey{Group: &Group{Platform: PlatformMixed}}) - stop := StartOpenAIStreamSSEKeepalive(c, time.Hour) - defer stop() - value, _ := c.Get(openAICompactSSEKeepaliveKey) - require.True(t, value.(*openAICompactSSEKeepalive).beat()) - var err error - switch ingress { - case APIProtocolChatCompletions: - _, err = svc.ForwardAsChatCompletions(c.Request.Context(), c, mixedTestAccount(), body, "", "") - case APIProtocolResponses: - _, err = svc.Forward(c.Request.Context(), c, mixedTestAccount(), body) - case APIProtocolAnthropic: - _, err = svc.ForwardAsAnthropic(c.Request.Context(), c, mixedTestAccount(), body, "", "") - } - require.Len(t, upstream.requests, len(responses)) - for i, req := range upstream.requests { - require.Equal(t, protocolUpstreamEndpoint(order[i]), req.URL.Path) - } - require.Equal(t, 1, strings.Count(rec.Body.String(), ": keepalive\n\n")) - if outcome == "refused" { - require.Error(t, err) - require.NotContains(t, rec.Body.String(), "lookup") - return - } - require.NoError(t, err) - require.Contains(t, rec.Body.String(), "lookup") - require.NotContains(t, rec.Body.String(), "does not support") - switch ingress { - case APIProtocolChatCompletions: - require.Contains(t, rec.Body.String(), "chat.completion.chunk") - require.NotContains(t, rec.Body.String(), "event: response.") - require.NotContains(t, rec.Body.String(), "event: message_") - case APIProtocolResponses: - require.Contains(t, rec.Body.String(), "event: response.completed") - require.NotContains(t, rec.Body.String(), "chat.completion.chunk") - require.NotContains(t, rec.Body.String(), "event: message_") - case APIProtocolAnthropic: - require.Contains(t, rec.Body.String(), "event: message_stop") - require.NotContains(t, rec.Body.String(), "chat.completion.chunk") - require.NotContains(t, rec.Body.String(), "event: response.") - } - }) - } - } -} - -func TestMixedOneRelayAccountSupportsModelSpecificProtocols(t *testing.T) { - account := mixedTestAccount() - account.Credentials["model_mapping"] = map[string]any{ - "qwen/qwen3.8-27b": "qwen/qwen3.8-27b", "z-ai/glm-5.3": "z-ai/glm-5.3", "deepseek-ai/deepseek-v4-pro": "deepseek-ai/deepseek-v4-pro", - } - before, err := json.Marshal(account) - require.NoError(t, err) - for _, model := range []struct{ public, target, protocol string }{ - {"qwen3.8-27b", "qwen/qwen3.8-27b", APIProtocolAnthropic}, - {"glm-5.3", "z-ai/glm-5.3", APIProtocolChatCompletions}, - {"deepseek-v4-pro", "deepseek-ai/deepseek-v4-pro", APIProtocolResponses}, - } { - for _, ingress := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - for _, stream := range []bool{false, true} { - for _, history := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/%s/stream=%t/history=%t", model.public, ingress, stream, history), func(t *testing.T) { - wire := mixedChatToolJSON - upstreamStream := stream - if stream { - wire = mixedChatToolSSE() - } - if model.protocol == APIProtocolResponses { - wire = mixedResponsesToolSSE() - upstreamStream = true - if ingress == APIProtocolResponses && !stream { - wire = `{"id":"resp_1","object":"response","model":"actual","status":"completed","output":[{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{}"}],"usage":{"input_tokens":9,"output_tokens":4}}` - upstreamStream = false - } - } - if model.protocol == APIProtocolAnthropic { - wire = toolAnthropicSSEStream() - upstreamStream = true - if ingress == APIProtocolAnthropic && !stream { - wire = `{"id":"msg_1","type":"message","role":"assistant","model":"actual","content":[{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}],"stop_reason":"tool_use","usage":{"input_tokens":10,"output_tokens":5}}` - upstreamStream = false - } - } - wire = strings.ReplaceAll(wire, `"actual"`, `"`+model.target+`"`) - responses := mixedResponsesForTarget(ingress, model.protocol, mixedHTTPResponse(200, wire, upstreamStream)) - upstream := &httpUpstreamRecorder{responses: responses} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - clientBody := mixedPlainClientBody(ingress, stream) - if history { - clientBody = mixedClientBody(ingress, stream) - } - body := []byte(strings.ReplaceAll(string(clientBody), `"alias"`, `"`+model.public+`"`)) - result, rec, _, err := mixedForward(t, svc, account, ingress, body) - require.NoError(t, err) - require.Equal(t, model.target, result.UpstreamModel) - require.Len(t, upstream.requests, mixedExpectedAttempts(ingress, model.protocol)) - require.Equal(t, protocolUpstreamEndpoint(model.protocol), upstream.lastReq.URL.Path) - require.Contains(t, rec.Body.String(), "lookup") - for i, req := range upstream.requests { - require.Equal(t, protocolUpstreamEndpoint(mixedExpectedProtocolOrder(ingress)[i]), req.URL.Path) - require.Equal(t, "upstream.example", req.URL.Host) - require.Equal(t, model.target, gjson.GetBytes(upstream.bodies[i], "model").String()) - if history { - require.Contains(t, string(upstream.bodies[i]), "prior") - } else { - require.Contains(t, string(upstream.bodies[i]), "hello") - } - } - }) - } - } - } - } - after, err := json.Marshal(account) - require.NoError(t, err) - require.JSONEq(t, string(before), string(after), "model routing must not persist guesses or create duplicate accounts") -} - -func TestMixedGLMProviderPrefixDoesNotAlterModelOrDefaults(t *testing.T) { - for _, model := range []string{"z-ai/glm-5.3", "zhipuai/glm-5.3"} { - body := []byte(`{"model":"` + model + `","output_config":{"effort":"xhigh"}}`) - got, changed := NormalizeGLM53AnthropicThinking(body, model) - require.True(t, changed) - require.Equal(t, model, gjson.GetBytes(got, "model").String()) - require.Equal(t, "max", gjson.GetBytes(got, "output_config.effort").String()) - body = []byte(`{"model":"` + model + `","messages":[]}`) - for _, normalizer := range []func([]byte, string) ([]byte, bool){NormalizeGLMOpenAIReasoningEffort, NormalizeGLM53AnthropicThinking} { - got, changed = normalizer(body, model) - require.False(t, changed) - require.Equal(t, body, got) - } - } - for _, model := range []string{"openai/glm-5.3", "unknown/glm-5.3", "z-ai/nested/glm-5.3", "qwen/qwen3.8-27b"} { - body := []byte(`{"reasoning_effort":"xhigh","output_config":{"effort":"xhigh"}}`) - for _, normalizer := range []func([]byte, string) ([]byte, bool){NormalizeGLMOpenAIReasoningEffort, NormalizeGLM53AnthropicThinking} { - got, changed := normalizer(body, model) - require.False(t, changed) - require.Equal(t, body, got) - } - } -} diff --git a/backend/internal/service/mixed_provider_rates.go b/backend/internal/service/mixed_provider_rates.go deleted file mode 100644 index c2d8902..0000000 --- a/backend/internal/service/mixed_provider_rates.go +++ /dev/null @@ -1,161 +0,0 @@ -package service - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "maps" - "math" - "strings" - - infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" -) - -// Stable storage keys. These describe the priced model, never the transport or account. -var mixedModelVendors = []string{"deepseek", "kimi", "zhipu", "qwen", "minimax", "mimo", "stepfun", "doubao", "yi", "baidu", "spark", "hunyuan", "openai", "anthropic", "gemini", "grok", "other"} - -// Request decoder rejects null entries: encoding/json otherwise turns null into -// float64(0), accidentally making a missing numeric input free. A null map is omission. -type MixedProviderRateMultipliers map[string]float64 - -func (rates *MixedProviderRateMultipliers) UnmarshalJSON(data []byte) error { - if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { - *rates = nil - return nil - } - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - values := make(map[string]float64, len(raw)) - for vendor, value := range raw { - if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { - return fmt.Errorf("mixed provider rate %q must be a number", vendor) - } - var rate float64 - if err := json.Unmarshal(value, &rate); err != nil { - return err - } - values[vendor] = rate - } - if err := ValidateMixedProviderRateMultipliers(values); err != nil { - return err - } - *rates = values - return nil -} - -func CloneMixedProviderRateMultipliers(rates map[string]float64) map[string]float64 { - return maps.Clone(rates) -} - -func ValidateMixedProviderRateMultipliers(rates map[string]float64) error { - for vendor, rate := range rates { - known := false - for _, allowed := range mixedModelVendors { - if vendor == allowed { - known = true - break - } - } - if !known || math.IsNaN(rate) || math.IsInf(rate, 0) || rate < 0 || rate > 999999.9999 { - return infraerrors.BadRequest("INVALID_MIXED_PROVIDER_RATE", fmt.Sprintf("invalid mixed provider rate for %q (expected a known vendor and finite rate between 0 and 999999.9999)", vendor)) - } - } - return nil -} - -// ModelVendor deliberately shares namespace validation with the four key scopes. -// Arbitrary namespaces and mismatched vendor/family pairs never borrow a vendor rate. -func ModelVendor(model string) string { - scope := ClassifyModelScope(model) - if scope == ModelScopeOpenAI || scope == ModelScopeAnthropic { - return scope - } - model = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(model)), "models/") - if scope == ModelScopeCN { - if _, family, ok := strings.Cut(model, "/"); ok { - model = family - } - if vendor := bareCNModelAliasVendor(model); vendor != "" { - return vendor - } - for _, entry := range []struct { - vendor string - families []string - }{ - {"deepseek", []string{"deepseek"}}, {"kimi", []string{"kimi", "moonshot"}}, - {"zhipu", []string{"glm", "chatglm", "cogview", "cogvideo"}}, {"qwen", []string{"qwen", "qwq"}}, - {"minimax", []string{"minimax", "abab"}}, {"mimo", []string{"mimo"}}, {"stepfun", []string{"step"}}, - {"doubao", []string{"doubao"}}, {"yi", []string{"yi"}}, {"baidu", []string{"ernie"}}, {"spark", []string{"spark"}}, {"hunyuan", []string{"hunyuan"}}, - } { - for _, family := range entry.families { - if modelFamily(model, family) { - return entry.vendor - } - } - } - } - for _, entry := range []struct{ vendor, namespace, family string }{ - {"gemini", "google", "gemini"}, {"gemini", "gemini", "gemini"}, - {"grok", "x-ai", "grok"}, {"grok", "xai", "grok"}, {"grok", "grok", "grok"}, - } { - if !strings.Contains(model, "/") && modelFamily(model, entry.family) { - return entry.vendor - } - if namespace, family, ok := strings.Cut(model, "/"); ok && namespace == entry.namespace && !strings.Contains(family, "/") && modelFamily(family, entry.family) { - return entry.vendor - } - } - return "other" -} - -// Mixed rates are absolute; an unconfigured vendor uses the standard 1x rate. -// An explicitly configured zero is meaningful. Ordinary groups ignore the map. -func (g *Group) RateMultiplierForModel(model string) float64 { - if g == nil { - return 1 - } - if g.Platform == PlatformMixed { - if rate, exists := g.MixedProviderRateMultipliers[ModelVendor(model)]; exists { - return rate - } - return 1 - } - return g.RateMultiplier -} - -// Projection/estimation must use the same concrete pricing normalization as -// Mixed settlement. Explicit group/channel cards always retain their identity. -func mixedBillingPricingIdentity(ctx context.Context, billing *BillingService, resolver *ModelPricingResolver, groupID *int64, group *Group, model string, upstream bool) string { - model = strings.TrimSpace(model) - if billing == nil || !upstream { - return model - } - if resolver != nil { - p := resolver.Resolve(ctx, PricingInput{Model: model, GroupID: groupID, Group: group}) - if p != nil && (p.Source == PricingSourceGroup || p.Source == PricingSourceChannel) { - return model - } - } - normalized := normalizeModelNameForPricing(strings.ToLower(lastSegment(model))) - if normalized != model && (isDeepSeekModel(normalized) || !billing.HasIdentifiedTokenPricing(model)) && billing.HasIdentifiedTokenPricing(normalized) { - return normalized - } - return model -} - -func mixedBillingModelPriced(ctx context.Context, billing *BillingService, resolver *ModelPricingResolver, groupID *int64, group *Group, model string) bool { - if resolver != nil { - p := resolver.Resolve(ctx, PricingInput{Model: model, GroupID: groupID, Group: group}) - if p != nil && (p.BasePricing != nil || p.Mode == BillingModeImage || p.Mode == BillingModePerRequest || p.Mode == BillingModeVideo) { - return true - } - } - if billing != nil { - p, err := billing.GetModelPricing(model) - return err == nil && p != nil - } - return false -} diff --git a/backend/internal/service/mixed_provider_rates_test.go b/backend/internal/service/mixed_provider_rates_test.go deleted file mode 100644 index 8df48f9..0000000 --- a/backend/internal/service/mixed_provider_rates_test.go +++ /dev/null @@ -1,291 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "math" - "sync" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/config" - "github.com/stretchr/testify/require" -) - -func TestMixedProviderRateValidationAndIdentity(t *testing.T) { - for _, body := range []string{`{"kimi":null}`, `{"kimi":"0.5"}`, `{"typo":1}`, `{"kimi":-1}`} { - var rates MixedProviderRateMultipliers - require.Error(t, json.Unmarshal([]byte(body), &rates)) - } - for model, vendor := range map[string]string{ - "deepseek-ai/deepseek-v4-pro": "deepseek", "moonshotai/kimi-k2.5": "kimi", "K3": "kimi", - "z-ai/glm-5.3": "zhipu", "Qwen/Qwen3.6": "qwen", "MiniMaxAI/MiniMax-M2.5": "minimax", - "xiaomimimo/mimo-v2-pro": "mimo", "stepfun-ai/step-3.5-flash": "stepfun", - "openai/gpt-5.5": "openai", "us.anthropic.claude-sonnet-4": "anthropic", - "google/gemini-3-pro": "gemini", "x-ai/grok-4": "grok", "untrusted/kimi-k2": "other", - "openai/glm-5": "other", "gpt-unknown/claude-sonnet-4": "other", "gemini-fake/anything": "other", "google/gemini-3/fake": "other", - "chatglm_turbo": "zhipu", "zhipuai/cogview-3": "zhipu", "thudm/cogvideo": "zhipu", "doubao-embedding-vision": "doubao", - "01-ai/yi-large": "yi", "baidu/ernie-4.0": "baidu", "spark-pro": "spark", "tencent/hunyuan-code": "hunyuan", "k2-thinking": "kimi", "k1.5": "kimi", "m2.7": "minimax", - } { - require.Equal(t, vendor, ModelVendor(model), model) - } - for _, rate := range []float64{-1, math.NaN(), math.Inf(1), 1000000} { - require.Error(t, ValidateMixedProviderRateMultipliers(map[string]float64{"kimi": rate})) - } - require.Error(t, ValidateMixedProviderRateMultipliers(map[string]float64{"unknown": 1})) - require.NoError(t, ValidateMixedProviderRateMultipliers(map[string]float64{"kimi": 0})) - group := &Group{Platform: PlatformMixed, RateMultiplier: 3, MixedProviderRateMultipliers: map[string]float64{"kimi": 0}} - require.Equal(t, 0.0, group.RateMultiplierForModel("kimi-k2")) - require.Equal(t, 1.0, group.RateMultiplierForModel("glm-5")) - group.Platform = PlatformOpenAI - require.Equal(t, 3.0, group.RateMultiplierForModel("kimi-k2")) -} - -func TestMixedProviderRateOverrideCache(t *testing.T) { - ctx := context.Background() - for _, override := range []*float64{nil, ptrFloat(0), ptrFloat(2)} { - t.Run(fmt.Sprint(override), func(t *testing.T) { - repo := &userGroupRateRepoHotpathStub{rate: override} - r := newUserGroupRateResolver(repo, nil, time.Minute, nil, "test") - const n = 32 - var wg sync.WaitGroup - got := make([]float64, n) - for i := range got { - wg.Add(1) - go func(i int) { defer wg.Done(); got[i] = r.Resolve(ctx, 1, 2, float64(i+1)) }(i) - } - wg.Wait() - for i, value := range got { - expected := float64(i + 1) - if override != nil { - expected = *override - } - require.Equal(t, expected, value) - require.Equal(t, expected, r.Resolve(ctx, 1, 2, float64(i+1))) - require.Equal(t, expected, r.ResolveSnapshotOnly(1, 2, float64(i+1))) - } - require.Equal(t, int64(1), repo.calls.Load()) - r.cache.Delete("1:2") - require.Equal(t, 99.0, r.ResolveSnapshotOnly(1, 2, 99)) - require.Equal(t, int64(1), repo.calls.Load(), "snapshot must not query repository") - r.Resolve(ctx, 1, 2, 99) - require.Equal(t, int64(2), repo.calls.Load()) - }) - } - repo := &userGroupRateRepoHotpathStub{err: errors.New("offline")} - r := newUserGroupRateResolver(repo, nil, time.Minute, nil, "test") - r.cache.Set("1:2", 77.0, time.Minute) // legacy resolved values have unknown override provenance - require.Equal(t, 2.0, r.ResolveSnapshotOnly(1, 2, 2)) - require.Equal(t, 2.0, r.Resolve(ctx, 1, 2, 2)) - require.Equal(t, 3.0, r.Resolve(ctx, 1, 2, 3)) -} - -func TestMixedProviderRateAdminStorageSemantics(t *testing.T) { - repo := &groupRepoStubForAdmin{} - svc := &adminServiceImpl{groupRepo: repo} - input := &CreateGroupInput{Name: "Mixed", Platform: PlatformMixed, RateMultiplier: 2, MixedProviderRateMultipliers: map[string]float64{"kimi": 0, "deepseek": 0.5}} - created, err := svc.CreateGroup(context.Background(), input) - require.NoError(t, err) - require.Equal(t, 0.5, created.MixedProviderRateMultipliers["deepseek"]) - input.MixedProviderRateMultipliers["deepseek"] = 7 - require.Equal(t, 0.5, created.MixedProviderRateMultipliers["deepseek"]) - repo.getByID = created - for _, body := range []string{`{}`, `{"mixed_provider_rate_multipliers":null}`} { - var update UpdateGroupInput - // Service input uses Go names. Handler JSON map omission/null both become nil. - var wire struct { - Rates map[string]float64 `json:"mixed_provider_rate_multipliers"` - } - require.NoError(t, json.Unmarshal([]byte(body), &wire)) - update.MixedProviderRateMultipliers = wire.Rates - got, err := svc.UpdateGroup(context.Background(), 1, &update) - require.NoError(t, err) - require.Equal(t, 0.5, got.MixedProviderRateMultipliers["deepseek"]) - } - got, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{MixedProviderRateMultipliers: map[string]float64{}}) - require.NoError(t, err) - require.Empty(t, got.MixedProviderRateMultipliers) - require.Equal(t, 1.0, got.RateMultiplierForModel("deepseek-v4-pro"), "clear returns to standard rate, never free") - _, err = svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{MixedProviderRateMultipliers: map[string]float64{"typo": 1}}) - require.Error(t, err) - svc.cfg = &config.Config{RunMode: config.RunModeSimple} - _, err = svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{MixedProviderRateMultipliers: map[string]float64{"kimi": 10}}) - require.NoError(t, err) - require.Empty(t, repo.updated.MixedProviderRateMultipliers) -} - -func TestMixedProviderRateSnapshotsAndClones(t *testing.T) { - groupID := int64(2) - group := &Group{ID: groupID, Platform: PlatformMixed, Status: StatusActive, RateMultiplier: 2, MixedProviderRateMultipliers: map[string]float64{"kimi": 0, "deepseek": 0.4}} - key := &APIKey{ID: 3, UserID: 1, GroupID: &groupID, Group: group, User: &User{ID: 1, Status: StatusActive}, Status: StatusActive} - svc := &APIKeyService{} - snapshot := svc.snapshotFromAPIKey(context.Background(), key) - data, err := json.Marshal(snapshot) - require.NoError(t, err) - var restored APIKeyAuthSnapshot - require.NoError(t, json.Unmarshal(data, &restored)) - materialized, used, err := svc.applyAuthCacheEntry("k", &APIKeyAuthCacheEntry{Snapshot: &restored}) - require.NoError(t, err) - require.True(t, used) - require.Equal(t, group.MixedProviderRateMultipliers, materialized.Group.MixedProviderRateMultipliers) - for _, clone := range []*Group{cloneSchedulerGroup(group), cloneGroupForDuplicate(group, "op"), materialized.Group} { - clone.MixedProviderRateMultipliers["deepseek"] = 9 - require.Equal(t, 0.4, group.MixedProviderRateMultipliers["deepseek"]) - } - snapshot.Group.MixedProviderRateMultipliers["kimi"] = 8 - require.Equal(t, 0.0, group.MixedProviderRateMultipliers["kimi"]) - _, used, err = svc.applyAuthCacheEntry("k", &APIKeyAuthCacheEntry{Snapshot: &APIKeyAuthSnapshot{Version: 26}}) - require.NoError(t, err) - require.False(t, used) -} - -func TestMixedProviderRateCatalogue(t *testing.T) { - repo := &modelScopeAccountRepo{accounts: []Account{{Platform: PlatformMixed, Credentials: map[string]any{"model_mapping": map[string]any{ - "kimi-k2": "deepseek-v4-pro", "gpt-5.5": "gpt-5.5", "gemini-3-pro": "gemini-3-pro", "blocked": "glm-5", - }}}}} - svc := &GatewayService{accountRepo: repo} - group := &Group{ID: 10, Platform: PlatformMixed} - require.Equal(t, []string{"openai", "cn", "other"}, svc.SupportedKeyProviders(context.Background(), group)) - require.Equal(t, []string{"deepseek", "zhipu", "openai", "gemini"}, group.SupportedModelVendors) - require.Equal(t, 1, repo.calls) -} - -func TestMixedProviderRateCatalogueAllScopeCrossCategoryAlias(t *testing.T) { - repo := &modelScopeAccountRepo{accounts: []Account{{Platform: PlatformMixed, Credentials: map[string]any{"model_mapping": map[string]any{ - "gpt-5.1": "deepseek-v4-flash", - }}}}} - svc := &GatewayService{accountRepo: repo} - group := &Group{ID: 10, Platform: PlatformMixed} - require.Empty(t, svc.SupportedKeyProviders(context.Background(), group), "restricted categories must still reject cross-category mappings") - require.Equal(t, []string{"deepseek"}, group.SupportedModelVendors, "all-scope keys still use the target vendor rate") - require.Equal(t, 1, repo.calls, "vendor projection must share the original catalogue read") -} - -func TestMixedProviderRatePreauthorization(t *testing.T) { - groupID := int64(2) - key := &APIKey{UserID: 1, GroupID: &groupID, Group: &Group{Platform: PlatformMixed, RateMultiplier: 7, MixedProviderRateMultipliers: map[string]float64{"kimi": 0, "deepseek": 0.4}}} - r := newUserGroupRateResolver(&userGroupRateRepoHotpathStub{}, nil, time.Minute, nil, "test") - gateway := &GatewayService{userGroupRateResolver: r} - openai := &OpenAIGatewayService{userGroupRateResolver: r} - for model, rate := range map[string]float64{"kimi-k2": 0, "deepseek-v4-pro": 0.4, "gpt-5.5": 1} { - require.Equal(t, rate, gateway.BalancePreauthorizationCostInput(context.Background(), key, model, time.Now(), "").RateMultiplier) - require.Equal(t, rate, openai.BalancePreauthorizationCostInput(context.Background(), key, model, time.Now(), "").RateMultiplier) - } -} - -func mixedRateTestPricing() map[string]*ModelPricing { - return map[string]*ModelPricing{ - "kimi-k2": {InputPricePerToken: 1e-6, OutputPricePerToken: 2e-6}, - "glm-5": {InputPricePerToken: 2e-6, OutputPricePerToken: 4e-6}, - "gpt-5.4": {InputPricePerToken: 3e-6, OutputPricePerToken: 6e-6}, - "claude-sonnet-4": {InputPricePerToken: 4e-6, OutputPricePerToken: 8e-6}, - } -} - -func TestMixedProviderRateBothBillingPaths(t *testing.T) { - for _, gateway := range []string{"openai", "anthropic"} { - for _, source := range []string{BillingModelSourceUpstream, BillingModelSourceRequested, BillingModelSourceChannelMapped, "fallback"} { - for _, override := range []*float64{nil, ptrFloat(0), ptrFloat(9)} { - t.Run(fmt.Sprintf("%s/%s/%v", gateway, source, override), func(t *testing.T) { - ctx := context.Background() - logs := &openAIRecordUsageLogRepoStub{inserted: true} - users := &openAIRecordUsageUserRepoStub{} - id := int64(2) - group := &Group{ID: id, Platform: PlatformMixed, RateMultiplier: 9, MixedProviderRateMultipliers: map[string]float64{"kimi": 0.2, "zhipu": 0.8, "openai": 2}} - key := &APIKey{ID: 3, UserID: 1, User: &User{ID: 1}, GroupID: &id, Group: group} - fields := ChannelUsageFields{OriginalModel: "kimi-k2", ChannelMappedModel: "gpt-5.4", BillingModelSource: source} - model := "glm-5" - switch source { - case BillingModelSourceRequested: - model = fields.OriginalModel - case BillingModelSourceChannelMapped: - model = fields.ChannelMappedModel - case "fallback": - fields.BillingModelSource = BillingModelSourceRequested - fields.OriginalModel = "unknown-rate-test" - fields.ChannelMappedModel = "" - } - rate := group.RateMultiplierForModel(model) - if override != nil { - rate = *override - } - r := newUserGroupRateResolver(&userGroupRateRepoHotpathStub{rate: override}, nil, time.Minute, nil, "test") - account := &Account{ID: 4, Platform: PlatformMixed, Type: AccountTypeAPIKey} - var billing *BillingService - if gateway == "openai" { - svc := newOpenAIRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}, nil) - svc.userGroupRateResolver = r - svc.billingService.fallbackPrices = mixedRateTestPricing() - billing = svc.billingService - require.NoError(t, svc.RecordUsage(ctx, &OpenAIRecordUsageInput{ - APIKey: key, User: key.User, Account: account, ChannelUsageFields: fields, InboundEndpoint: "/v1/messages", - Result: &OpenAIForwardResult{RequestID: "mixed-rate", Model: fields.OriginalModel, UpstreamModel: "glm-5", Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 50}}, - })) - } else { - svc := newGatewayRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}) - svc.userGroupRateResolver = r - svc.billingService.fallbackPrices = mixedRateTestPricing() - billing = svc.billingService - require.NoError(t, svc.RecordUsage(ctx, &RecordUsageInput{ - APIKey: key, User: key.User, Account: account, ChannelUsageFields: fields, - // The original Messages forwarder puts its concrete billing model in Model. - Result: &ForwardResult{RequestID: "mixed-rate", Model: "glm-5", UpstreamModel: "glm-5", Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}}, - })) - } - expected, err := billing.CalculateCost(model, UsageTokens{InputTokens: 100, OutputTokens: 50}, rate) - require.NoError(t, err) - require.NotNil(t, logs.lastLog) - require.InDelta(t, expected.TotalCost, logs.lastLog.TotalCost, 1e-12) - require.InDelta(t, expected.ActualCost, logs.lastLog.ActualCost, 1e-12) - require.InDelta(t, expected.ActualCost, users.lastAmount, 1e-12) - require.Equal(t, rate, logs.lastLog.RateMultiplier) - }) - } - } - } -} - -func TestMixedProviderRateResponseModelGuards(t *testing.T) { - for _, gateway := range []string{"openai", "anthropic"} { - for _, responseRate := range []float64{0, 0.25, 8} { - t.Run(fmt.Sprintf("%s/%g", gateway, responseRate), func(t *testing.T) { - logs := &openAIRecordUsageLogRepoStub{inserted: true} - users := &openAIRecordUsageUserRepoStub{} - id := int64(2) - group := &Group{ID: id, Platform: PlatformMixed, RateMultiplier: 9, MixedProviderRateMultipliers: map[string]float64{"kimi": responseRate, "zhipu": 0.5}} - key := &APIKey{ID: 3, UserID: 1, User: &User{ID: 1}, GroupID: &id, Group: group} - fields := ChannelUsageFields{OriginalModel: "glm-5", BillingModelSource: BillingModelSourceResponse} - account := &Account{ID: 4, Platform: PlatformMixed, Type: AccountTypeAPIKey} - var billing *BillingService - if gateway == "openai" { - svc := newOpenAIRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}, nil) - svc.billingService.fallbackPrices = mixedRateTestPricing() - billing = svc.billingService - require.NoError(t, svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{APIKey: key, User: key.User, Account: account, ChannelUsageFields: fields, - Result: &OpenAIForwardResult{RequestID: "response-rate", Model: "glm-5", UpstreamResponseModel: "kimi-k2", Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 50}}, - })) - } else { - svc := newGatewayRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}) - svc.billingService.fallbackPrices = mixedRateTestPricing() - billing = svc.billingService - require.NoError(t, svc.RecordUsage(context.Background(), &RecordUsageInput{APIKey: key, User: key.User, Account: account, ChannelUsageFields: fields, - Result: &ForwardResult{RequestID: "response-rate", Model: "glm-5", UpstreamResponseModel: "kimi-k2", Usage: ClaudeUsage{InputTokens: 100, OutputTokens: 50}}, - })) - } - model, rate := "glm-5", 0.5 - if responseRate == 0.25 { - model, rate = "kimi-k2", responseRate - } - expected, err := billing.CalculateCost(model, UsageTokens{InputTokens: 100, OutputTokens: 50}, rate) - require.NoError(t, err) - require.InDelta(t, expected.TotalCost, logs.lastLog.TotalCost, 1e-12) - require.InDelta(t, expected.ActualCost, logs.lastLog.ActualCost, 1e-12) - require.Equal(t, rate, logs.lastLog.RateMultiplier) - }) - } - } -} diff --git a/backend/internal/service/mixed_responses_json_regression_test.go b/backend/internal/service/mixed_responses_json_regression_test.go deleted file mode 100644 index 1d5f472..0000000 --- a/backend/internal/service/mixed_responses_json_regression_test.go +++ /dev/null @@ -1,213 +0,0 @@ -//go:build unit - -package service - -import ( - "errors" - "fmt" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestMixedResponsesJSONCompletionIndependentOfUsage(t *testing.T) { - for _, status := range []string{"completed", "incomplete", "in_progress", ""} { - for _, usage := range []bool{false, true} { - t.Run(fmt.Sprintf("status=%s/usage=%t", status, usage), func(t *testing.T) { - body := fmt.Sprintf(`{"id":"resp_1","object":"response","status":%q,"model":"actual","output":[{"type":"message","content":[{"type":"output_text","text":"OK"}]}]`, status) - if usage { - body += `,"usage":{"input_tokens":9,"output_tokens":4}` - } - body += "}" - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(200, body, false)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), APIProtocolResponses, mixedClientBody(APIProtocolResponses, false)) - require.Len(t, upstream.requests, 1) - require.NotNil(t, result) - if usage { - require.Equal(t, 9, result.Usage.InputTokens) - require.Equal(t, 4, result.Usage.OutputTokens) - } - if status == "completed" || status == "incomplete" { - require.NoError(t, err) - require.Equal(t, 200, rec.Code) - require.Contains(t, rec.Body.String(), "OK") - } else { - require.Error(t, err) - require.Equal(t, 502, rec.Code) - require.Contains(t, rec.Body.String(), `"error"`) - } - }) - } - } -} - -func TestMixedMalformedResponsesTerminalNeverSucceeds(t *testing.T) { - for _, stream := range []bool{false, true} { - t.Run(fmt.Sprint(stream), func(t *testing.T) { - prefix, _, _ := mixedFailurePayload(APIProtocolResponses, true, "truncated") - body := prefix + "event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":\n\n" - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(200, body, true)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), APIProtocolResponses, mixedClientBody(APIProtocolResponses, stream)) - require.Error(t, err) - require.NotNil(t, result) - require.Len(t, upstream.requests, 1) - require.Equal(t, 9, result.Usage.InputTokens) - require.Equal(t, 4, result.Usage.OutputTokens) - require.Contains(t, rec.Body.String(), `"error"`) - require.NotContains(t, rec.Body.String(), `data: {"type":"response.completed"`) - }) - } -} - -func TestMixedNamedChatErrorNeverFinalizes(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, named := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/named=%t", ingress, named), func(t *testing.T) { - body := "" - if named { - body = "event: error\ndata: " + `{"message":"overloaded","usage":{"prompt_tokens":9,"completion_tokens":4}}` + "\n\n" - } else { - body = "data: " + `{"type":"error","message":"overloaded","usage":{"prompt_tokens":9,"completion_tokens":4}}` + "\n\n" - } - body += "data: [DONE]\n\n" - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(ingress, APIProtocolChatCompletions, mixedHTTPResponse(200, body, true))} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, true)) - require.Error(t, err) - require.NotNil(t, result) - require.Len(t, upstream.requests, mixedExpectedAttempts(ingress, APIProtocolChatCompletions)) - require.Equal(t, 9, result.Usage.InputTokens) - require.Equal(t, 4, result.Usage.OutputTokens) - require.Contains(t, rec.Body.String(), `"error"`) - for _, terminal := range []string{"response.completed", "message_stop", "[DONE]"} { - require.NotContains(t, rec.Body.String(), terminal) - } - }) - } - } -} - -func TestMixedReversedNamedChatErrorNeverFinalizes(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, priorOutput := range []bool{false, true} { - for _, finalBlank := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/output=%t/blank=%t", ingress, priorOutput, finalBlank), func(t *testing.T) { - body := "" - if priorOutput { - body = "data: " + `{"id":"cc_1","model":"actual","choices":[{"index":0,"delta":{"role":"assistant","content":"partial"},"finish_reason":null}]}` + "\n\n" - } - body += "data: " + `{"message":"overloaded","usage":{"prompt_tokens":9,"completion_tokens":4}}` + "\nevent: error\n" - if finalBlank { - body += "\ndata: [DONE]\n\n" - } - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(ingress, APIProtocolChatCompletions, mixedHTTPResponse(200, body, true))} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, true)) - require.Error(t, err) - var replay *UpstreamFailoverError - require.False(t, errors.As(err, &replay)) - require.NotNil(t, result) - require.Len(t, upstream.requests, mixedExpectedAttempts(ingress, APIProtocolChatCompletions)) - require.Equal(t, 9, result.Usage.InputTokens) - require.Equal(t, 4, result.Usage.OutputTokens) - require.Contains(t, rec.Body.String(), `"error"`) - if priorOutput { - require.Contains(t, rec.Body.String(), "partial") - } - for _, terminal := range []string{"response.completed", "message_stop", "[DONE]"} { - require.NotContains(t, rec.Body.String(), terminal) - } - }) - } - } - } -} - -func TestMixedBufferedResponsesUsesValidatedTerminal(t *testing.T) { - for _, first := range []string{"in_progress", "malformed", "completed"} { - t.Run(first, func(t *testing.T) { - body := "event: response.completed\ndata: " - if first == "malformed" { - body += `{"type":"response.completed","response":` - } else { - body += fmt.Sprintf(`{"type":"response.completed","response":{"id":"resp_first","status":%q,"output":[{"type":"message","content":[{"type":"output_text","text":"FIRST"}]}],"usage":{"input_tokens":9,"output_tokens":4}}}`, first) - } - body += "\n\nevent: response.completed\ndata: " + `{"type":"response.completed","response":{"id":"resp_last","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"LAST"}]}],"usage":{"input_tokens":12,"output_tokens":6}}}` + "\n\n" - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(200, body, true)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), APIProtocolResponses, mixedClientBody(APIProtocolResponses, false)) - require.Len(t, upstream.requests, 1) - require.NotNil(t, result) - if first == "completed" { - require.NoError(t, err) - require.Equal(t, 200, rec.Code) - require.Contains(t, rec.Body.String(), "resp_last") - require.Contains(t, rec.Body.String(), "LAST") - require.NotContains(t, rec.Body.String(), "FIRST") - require.Equal(t, 12, result.Usage.InputTokens) - require.Equal(t, 6, result.Usage.OutputTokens) - } else { - require.Error(t, err) - var replay *UpstreamFailoverError - require.False(t, errors.As(err, &replay)) - require.Equal(t, 502, rec.Code) - require.Contains(t, rec.Body.String(), `"error"`) - require.NotContains(t, rec.Body.String(), "FIRST") - } - }) - } -} - -func TestMixedPendingChatUsageSurvivesReadFailure(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - t.Run(ingress, func(t *testing.T) { - body := "data: " + `{"choices":[],"usage":{"prompt_tokens":9,"completion_tokens":4}}` + "\n" - response := mixedHTTPResponse(200, body, true) - response.Body = &mixedFailedReadBody{body: strings.NewReader(body)} - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(ingress, APIProtocolChatCompletions, response)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, true)) - require.Error(t, err) - var replay *UpstreamFailoverError - require.False(t, errors.As(err, &replay)) - require.Len(t, upstream.requests, mixedExpectedAttempts(ingress, APIProtocolChatCompletions)) - require.NotNil(t, result) - require.Equal(t, 9, result.Usage.InputTokens) - require.Equal(t, 4, result.Usage.OutputTokens) - require.Contains(t, rec.Body.String(), `"error"`) - for _, terminal := range []string{"response.completed", "message_stop", "[DONE]"} { - require.NotContains(t, rec.Body.String(), terminal) - } - }) - } -} - -func TestMixedBufferedResponsesNamedErrorCannotBeMasked(t *testing.T) { - for _, reversed := range []bool{false, true} { - t.Run(fmt.Sprint(reversed), func(t *testing.T) { - event := "event: error\n" - data := "data: " + `{"type":"server_error","message":"overloaded","usage":{"input_tokens":9,"output_tokens":4}}` + "\n" - body := event + data - if reversed { - body = data + event - } - body += "\nevent: response.completed\ndata: " + `{"type":"response.completed","response":{"id":"resp_last","status":"completed","output":[],"usage":{"input_tokens":9,"output_tokens":4}}}` + "\n\n" - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(200, body, true)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), APIProtocolResponses, mixedClientBody(APIProtocolResponses, false)) - require.Error(t, err) - var replay *UpstreamFailoverError - require.False(t, errors.As(err, &replay)) - require.Len(t, upstream.requests, 1) - require.NotNil(t, result) - require.Equal(t, 9, result.Usage.InputTokens) - require.Equal(t, 4, result.Usage.OutputTokens) - require.Equal(t, 502, rec.Code) - require.Contains(t, rec.Body.String(), `"error"`) - require.NotContains(t, rec.Body.String(), "resp_last") - }) - } -} diff --git a/backend/internal/service/mixed_usage_billing_test.go b/backend/internal/service/mixed_usage_billing_test.go deleted file mode 100644 index 6fa24cd..0000000 --- a/backend/internal/service/mixed_usage_billing_test.go +++ /dev/null @@ -1,277 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "fmt" - "net/http" - "strings" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -func mixedBillingInput(result *OpenAIForwardResult, account *Account) *OpenAIRecordUsageInput { - groupID := int64(218) - rates := make(map[string]float64, len(mixedModelVendors)) - for _, vendor := range mixedModelVendors { - rates[vendor] = 0.1 - } - return &OpenAIRecordUsageInput{ - Result: result, Account: account, User: &User{ID: 20}, - // Fixture expectations below use official off-peak cards. Production - // requests freeze their own PricingAt; tests must not depend on wall time. - PricingAt: time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC), - APIKey: &APIKey{ID: 10, GroupID: &groupID, Group: &Group{ID: groupID, Platform: PlatformMixed, RateMultiplier: 0.1, MixedProviderRateMultipliers: rates}}, - ChannelUsageFields: ChannelUsageFields{OriginalModel: result.Model}, - } -} - -func TestMixedBillingUnknownUpstreamNeverUsesClientAliasOrFamilyGuess(t *testing.T) { - for _, source := range []string{"", BillingModelSourceUpstream} { - for _, model := range []string{"qwen3.8-27b-fp8", "vendor-claude-unpriced", "not-a-model-gpt-5.5-unpriced"} { - t.Run(source+"/"+model, func(t *testing.T) { - logs := &openAIRecordUsageLogRepoStub{inserted: true} - users := &openAIRecordUsageUserRepoStub{} - svc := newOpenAIRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}, nil) - in := mixedBillingInput(&OpenAIForwardResult{RequestID: "missing-price", Model: "gpt-5.1", BillingModel: "gpt-5.1", UpstreamModel: model, - Usage: OpenAIUsage{InputTokens: 200, OutputTokens: 50, CacheReadInputTokens: 80}}, mixedTestAccount()) - in.BillingModelSource = source - in.ChannelMappedModel = "claude-sonnet-4" - require.NoError(t, svc.RecordUsage(context.Background(), in)) - require.Equal(t, 1, logs.calls) - require.Equal(t, 120, logs.lastLog.InputTokens) - require.Equal(t, 80, logs.lastLog.CacheReadTokens) - require.Equal(t, model, *logs.lastLog.UpstreamModel) - require.Zero(t, logs.lastLog.TotalCost) - require.Zero(t, logs.lastLog.ActualCost) - require.Zero(t, users.deductCalls) - }) - } - } -} - -func TestMixedDomesticAliasForwardsAndBillsUnchangedUpstreamTarget(t *testing.T) { - for _, upstreamModel := range []string{"deepseek-v4-flash", "unpriced-domestic-model"} { - account := mixedTestAccount() - account.Credentials["model_mapping"] = map[string]any{"deepseek-ai/deepseek-client-alias": upstreamModel} - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(APIProtocolResponses, APIProtocolChatCompletions, mixedHTTPResponse(200, mixedChatToolJSON, false))} - forwarder := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - body := []byte(strings.ReplaceAll(string(mixedClientBody(APIProtocolResponses, false)), `"alias"`, `"deepseek-client-alias"`)) - result, _, _, err := mixedForward(t, forwarder, account, APIProtocolResponses, body) - require.NoError(t, err) - require.Equal(t, upstreamModel, result.UpstreamModel) - require.Equal(t, upstreamModel, gjson.GetBytes(upstream.lastBody, "model").String()) - logs := &openAIRecordUsageLogRepoStub{inserted: true} - users := &openAIRecordUsageUserRepoStub{} - billing := newOpenAIRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}, nil) - input := mixedBillingInput(result, account) - require.NoError(t, billing.RecordUsage(context.Background(), input)) - require.Equal(t, upstreamModel, *logs.lastLog.UpstreamModel) - require.Equal(t, 0.1, input.APIKey.Group.RateMultiplier) - if upstreamModel == "unpriced-domestic-model" { - require.Zero(t, logs.lastLog.ActualCost) - require.Zero(t, users.deductCalls) - } else { - expected := expectedOpenAICost(t, billing, upstreamModel, result.Usage, 0.1) - require.InDelta(t, canonicalUsageActualCost(expected), logs.lastLog.ActualCost, 1e-12) - } - } -} - -func TestMixedBillingExplicitSourcesAndLegacyCandidatesPreserved(t *testing.T) { - for _, tt := range []struct{ platform, source, actual, response, want string }{ - {PlatformMixed, BillingModelSourceRequested, "unknown-model", "", "gpt-5.1"}, - {PlatformMixed, BillingModelSourceChannelMapped, "unknown-model", "", "gpt-5.4"}, - {PlatformMixed, BillingModelSourceResponse, "gpt-5.4", "gpt-5.1", "gpt-5.1"}, - {PlatformOpenAI, "", "unknown-model", "", "gpt-5.1"}, - {PlatformMixed, "", "provider/deepseek-v4-flash", "", "deepseek-v4-flash"}, - {PlatformMixed, "", "gpt5.4", "", "gpt-5.4"}, - } { - t.Run(fmt.Sprintf("%s/%s/%s", tt.platform, tt.source, tt.actual), func(t *testing.T) { - logs := &openAIRecordUsageLogRepoStub{inserted: true} - svc := newOpenAIRecordUsageServiceForTest(logs, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) - in := mixedBillingInput(&OpenAIForwardResult{RequestID: "explicit", Model: "gpt-5.1", UpstreamModel: tt.actual, UpstreamResponseModel: tt.response, - Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 20}}, &Account{ID: 30, Platform: tt.platform, Type: AccountTypeAPIKey}) - in.BillingModelSource = tt.source - in.ChannelMappedModel = "gpt-5.4" - require.NoError(t, svc.RecordUsage(context.Background(), in)) - expected := expectedOpenAICost(t, svc, tt.want, in.Result.Usage, 0.1) - require.InDelta(t, expected.ActualCost, logs.lastLog.ActualCost, 1e-12) - }) - } -} - -func TestMixedBillingUserGroupAndAccountMultipliersPreserved(t *testing.T) { - logs := &openAIRecordUsageLogRepoStub{inserted: true} - users := &openAIRecordUsageUserRepoStub{} - userRate := 0.2 - rateRepo := &openAIUserGroupRateRepoStub{rate: &userRate} - svc := newOpenAIRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}, rateRepo) - account := mixedTestAccount() - account.RateMultiplier = testPtrFloat64(0.7) - in := mixedBillingInput(&OpenAIForwardResult{RequestID: "multipliers", Model: "alias", UpstreamModel: "gpt-5.4", - Usage: OpenAIUsage{InputTokens: 1000, OutputTokens: 100}}, account) - require.NoError(t, svc.RecordUsage(context.Background(), in)) - expected := expectedOpenAICost(t, svc, "gpt-5.4", in.Result.Usage, userRate) - require.Equal(t, 0.1, in.APIKey.Group.RateMultiplier) - require.Equal(t, userRate, logs.lastLog.RateMultiplier) - require.Equal(t, 0.7, *logs.lastLog.AccountRateMultiplier) - require.InDelta(t, canonicalUsageActualCost(expected), logs.lastLog.ActualCost, 1e-12) - require.InDelta(t, logs.lastLog.ActualCost, users.lastAmount, 1e-12) -} - -func TestMixedBillingConfiguredGroupAndChannelOverrideStillPriceUnknownActual(t *testing.T) { - for _, channel := range []bool{false, true} { - t.Run(fmt.Sprintf("channel=%t", channel), func(t *testing.T) { - logs := &openAIRecordUsageLogRepoStub{inserted: true} - svc := newOpenAIRecordUsageServiceForTest(logs, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) - in := mixedBillingInput(&OpenAIForwardResult{RequestID: "override", Model: "gpt-5.1", UpstreamModel: "unpriced-claude-vendor", - Usage: OpenAIUsage{InputTokens: 100, OutputTokens: 20}}, mixedTestAccount()) - card := ChannelModelPricing{Platform: PlatformMixed, Models: []string{"unpriced-claude-vendor"}, BillingMode: BillingModeToken, - InputPrice: testPtrFloat64(1e-6), OutputPrice: testPtrFloat64(2e-6)} - if channel { - // Reuse the existing resolver fixture's group and platform binding. - card.Platform = PlatformAnthropic - in.APIKey.GroupID = groupIDPtr() - in.APIKey.Group.ID = *in.APIKey.GroupID - in.APIKey.Group.Platform = PlatformAnthropic - svc.resolver = newResolverWithChannel(t, []ChannelModelPricing{card}) - } else { - in.APIKey.Group.ModelPricing = []ChannelModelPricing{card} - svc.resolver = NewModelPricingResolver(nil, svc.billingService) - } - require.NoError(t, svc.RecordUsage(context.Background(), in)) - require.InDelta(t, (100*1e-6+20*2e-6)*0.1, logs.lastLog.ActualCost, 1e-12) - }) - } -} - -func mixedBillingChatResponse(model, usage string, stream bool) *http.Response { - if stream { - return mixedHTTPResponse(200, "data: "+fmt.Sprintf(`{"id":"mixed-cost","model":%q,"choices":[{"index":0,"delta":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`, model)+ - "\n\ndata: "+`{"choices":[],"usage":`+usage+"}\n\ndata: [DONE]\n\n", true) - } - return mixedHTTPResponse(200, fmt.Sprintf(`{"id":"mixed-cost","model":%q,"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":%s}`, model, usage), false) -} - -func TestMixedBillingDeepSeekStandardAndVendorUsageAcrossIngress(t *testing.T) { - gin.SetMode(gin.TestMode) - for _, protocol := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, stream := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/stream=%t", protocol, stream), func(t *testing.T) { - var firstLog *UsageLog - for _, usage := range []string{ - `{"prompt_tokens":200,"completion_tokens":5,"total_tokens":205,"prompt_tokens_details":{"cached_tokens":128}}`, - `{"prompt_tokens":200,"completion_tokens":5,"total_tokens":205,"prompt_cache_hit_tokens":128,"prompt_cache_miss_tokens":72}`, - } { - logs := &openAIRecordUsageLogRepoStub{inserted: true} - users := &openAIRecordUsageUserRepoStub{} - svc := newOpenAIRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}, nil, rawChatCompletionsTestConfig()) - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(protocol, APIProtocolChatCompletions, mixedBillingChatResponse("deepseek-v4-flash", usage, stream))} - svc.httpUpstream = upstream - account := mixedTestAccount() - account.Credentials["model_mapping"] = map[string]any{"gpt-5.1": "deepseek-v4-flash"} - body := []byte(strings.ReplaceAll(string(mixedClientBody(protocol, stream)), `"alias"`, `"gpt-5.1"`)) - result, _, _, err := mixedForward(t, svc, account, protocol, body) - require.NoError(t, err) - require.Equal(t, "deepseek-v4-flash", result.UpstreamModel) - require.Equal(t, 200, result.Usage.InputTokens) - require.NoError(t, svc.RecordUsage(context.Background(), mixedBillingInput(result, account))) - require.Equal(t, 1, logs.calls) - require.Equal(t, 1, users.deductCalls) - require.Equal(t, 72, logs.lastLog.InputTokens) - require.Equal(t, 128, logs.lastLog.CacheReadTokens) - require.Equal(t, 0.1, logs.lastLog.RateMultiplier) - expected := expectedOpenAICost(t, svc, "deepseek-v4-flash", result.Usage, 0.1) - require.InDelta(t, canonicalUsageActualCost(expected), logs.lastLog.ActualCost, 1e-12) - if firstLog != nil { - require.InDelta(t, firstLog.ActualCost, logs.lastLog.ActualCost, 1e-12) - } - firstLog = logs.lastLog - } - }) - } - } -} - -func TestMixedBillingDeepSeekExplicitStandardZeroWins(t *testing.T) { - for _, standard := range []string{ - `"input_tokens_details":{"cached_tokens":0}`, - `"prompt_tokens_details":{"cached_tokens":0}`, - `"cache_read_input_tokens":0`, - } { - usage, ok := openAIUsageFromGJSON(gjson.Parse(`{"prompt_tokens":200,"completion_tokens":5,"prompt_cache_hit_tokens":128,` + standard + `}`)) - require.True(t, ok) - require.Equal(t, 200, usage.InputTokens) - require.Zero(t, usage.CacheReadInputTokens) - } -} - -func TestMixedBillingAnthropicCacheBreakdownAcrossIngressAndRefusal(t *testing.T) { - gin.SetMode(gin.TestMode) - usageJSON := `{"input_tokens":10,"output_tokens":5,"cache_read_input_tokens":20,"cache_creation_input_tokens":100,"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":70}}` - for _, protocol := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, stream := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/stream=%t", protocol, stream), func(t *testing.T) { - logs := &openAIRecordUsageLogRepoStub{inserted: true} - users := &openAIRecordUsageUserRepoStub{} - svc := newOpenAIRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}, nil, rawChatCompletionsTestConfig()) - payload := `{"id":"msg_cost","type":"message","role":"assistant","model":"claude-sonnet-5-5","content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","usage":` + usageJSON + `}` - if protocol != APIProtocolAnthropic || stream { - payload = "event: message_start\ndata: " + `{"type":"message_start","message":{"id":"msg_cost","type":"message","role":"assistant","model":"claude-sonnet-5-5","content":[],"usage":` + usageJSON + "}}\n\n" + - "event: content_block_start\ndata: " + `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}` + "\n\n" + - "event: content_block_delta\ndata: " + `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}` + "\n\n" + - "event: content_block_stop\ndata: " + `{"type":"content_block_stop","index":0}` + "\n\n" + - "event: message_delta\ndata: " + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}` + "\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n" - } - responses := mixedResponsesForTarget(protocol, APIProtocolAnthropic, mixedHTTPResponse(200, payload, protocol != APIProtocolAnthropic || stream)) - upstream := &httpUpstreamRecorder{responses: responses} - svc.httpUpstream = upstream - account := mixedTestAccount() - account.Credentials["model_mapping"] = map[string]any{"alias": "claude-sonnet-5-5"} - result, _, _, err := mixedForward(t, svc, account, protocol, mixedClientBody(protocol, stream)) - require.NoError(t, err) - require.Len(t, upstream.requests, len(responses)) - require.Zero(t, logs.calls, "protocol refusals must not record or bill usage") - require.Zero(t, users.deductCalls) - require.Equal(t, 130, result.Usage.InputTokens) - require.Equal(t, 30, result.Usage.CacheCreation5mTokens) - require.Equal(t, 70, result.Usage.CacheCreation1hTokens) - require.NoError(t, svc.RecordUsage(context.Background(), mixedBillingInput(result, account))) - require.Equal(t, 1, logs.calls) - require.Equal(t, 1, users.deductCalls) - require.Equal(t, 10, logs.lastLog.InputTokens) - require.Equal(t, 100, logs.lastLog.CacheCreationTokens) - require.Equal(t, 30, logs.lastLog.CacheCreation5mTokens) - require.Equal(t, 70, logs.lastLog.CacheCreation1hTokens) - expected, err := svc.billingService.CalculateCost("claude-sonnet-5-5", UsageTokens{InputTokens: 10, OutputTokens: 5, - CacheReadTokens: 20, CacheCreationTokens: 100, CacheCreation5mTokens: 30, CacheCreation1hTokens: 70}, 0.1) - require.NoError(t, err) - require.InDelta(t, 30*2.5e-6+70*4e-6, logs.lastLog.CacheCreationCost, 1e-12) - require.InDelta(t, expected.ActualCost, logs.lastLog.ActualCost, 1e-12) - }) - } - } -} - -func TestMixedBillingOpenAIUsageParsesDirectCacheBreakdown(t *testing.T) { - for _, fields := range []string{ - `"cache_creation_5m_tokens":30,"cache_creation_1h_tokens":70`, - `"cache_creation":{"ephemeral_5m_input_tokens":30,"ephemeral_1h_input_tokens":70}`, - } { - usage, ok := openAIUsageFromGJSON(gjson.Parse(`{"input_tokens":130,"cache_creation_input_tokens":100,"cache_read_input_tokens":20,` + fields + `}`)) - require.True(t, ok) - require.Equal(t, 130, usage.InputTokens) - require.Equal(t, 100, usage.CacheCreationInputTokens) - require.Equal(t, 30, usage.CacheCreation5mTokens) - require.Equal(t, 70, usage.CacheCreation1hTokens) - var progressive OpenAIUsage - (&OpenAIGatewayService{}).parseSSEUsageBytes([]byte(`{"type":"response.in_progress","response":{"usage":`+`{"input_tokens":130,"cache_creation_input_tokens":100,"cache_read_input_tokens":20,`+fields+`}}}`), &progressive) - require.Equal(t, usage, progressive) - } -} diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index be47100..f9bb22f 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -21,12 +21,8 @@ type PlazaOfficialPricing struct { // PlazaModel 模型广场中单个模型条目:按实收口径合成的展示定价 + 官方参考价。 type PlazaModel struct { - Name string - Platform string - // Mixed uses the priced model's absolute vendor rate, before a user override. - RateMultiplier *float64 - ModelVendor string - BillingModel string + Name string + Platform string Pricing *ChannelModelPricing OfficialPricing *PlazaOfficialPricing // LongContextBasis 多档时的计价基准(整单 / 仅超出部分),单档为空。 @@ -212,18 +208,10 @@ func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error return pg.Models[i].Platform < pg.Models[j].Platform }) g := groupEnt[gid] - if g.Platform == PlatformMixed { - s.expandMixedBillingModels(ctx, pg, g) - } for j := range pg.Models { - if g.Platform == PlatformMixed { - rate := g.RateMultiplierForModel(firstNonEmpty(pg.Models[j].BillingModel, pg.Models[j].Name)) - pg.Models[j].RateMultiplier = &rate - pg.Models[j].ModelVendor = ModelVendor(firstNonEmpty(pg.Models[j].BillingModel, pg.Models[j].Name)) - } if pricingNeedsFallback(pg.Models[j].Pricing) { one := []SupportedModel{{ - Name: firstNonEmpty(pg.Models[j].BillingModel, pg.Models[j].Name), + Name: pg.Models[j].Name, Platform: pg.Models[j].Platform, Pricing: pg.Models[j].Pricing, }} @@ -231,7 +219,7 @@ func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error pg.Models[j].Pricing = one[0].Pricing } s.fillDisplayPricing(ctx, &pg.Models[j], g) - pg.Models[j].OfficialPricing = s.lookupOfficialPricing(ctx, firstNonEmpty(pg.Models[j].BillingModel, pg.Models[j].Name), officialMemo) + pg.Models[j].OfficialPricing = s.lookupOfficialPricing(ctx, pg.Models[j].Name, officialMemo) } out = append(out, *pg) } @@ -245,47 +233,13 @@ func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error return out, nil } -func (s *ModelPlazaService) expandMixedBillingModels(ctx context.Context, pg *PlazaGroup, g *Group) { - catalog, ok := s.listedModels.(interface { - MixedBillingModels(context.Context, *Group, string) []string - }) - if !ok { - return - } - models := make([]PlazaModel, 0, len(pg.Models)) - for _, model := range pg.Models { - targets := catalog.MixedBillingModels(ctx, g, model.Name) - if len(targets) == 0 { - models = append(models, model) - continue - } - for _, target := range targets { - row := model - row.BillingModel = target - pricingCtx := WithResolvedTargetPlatform(ctx, model.Platform) - if s.resolver != nil { - // The public alias may carry a different token or media price. - // Resolve this candidate's own card rather than inheriting it. - row.Pricing = nil - p := s.resolver.Resolve(pricingCtx, PricingInput{Model: row.BillingModel, GroupID: &g.ID, Group: g}) - if p != nil && p.channelPricing != nil { - cloned := p.channelPricing.Clone() - row.Pricing = &cloned - } - } - models = append(models, row) - } - } - pg.Models = models -} - // fillDisplayPricing 把模型的展示定价换成实收口径: // token 模型取计费阶梯表(单价与档位均由真实计费函数得出), // 图片/按次模型(或阶梯表不可用时)沿用渠道定价与分组图片档位价。 func (s *ModelPlazaService) fillDisplayPricing(ctx context.Context, m *PlazaModel, g *Group) { if s.billingService != nil && s.resolver != nil { sched, err := s.billingService.ResolveContextPricingSchedule(ctx, s.resolver, ContextPricingScheduleInput{ - Model: firstNonEmpty(m.BillingModel, m.Name), + Model: m.Name, Group: g, Platform: m.Platform, }) diff --git a/backend/internal/service/native_provider_restore_test.go b/backend/internal/service/native_provider_restore_test.go new file mode 100644 index 0000000..256a7a6 --- /dev/null +++ b/backend/internal/service/native_provider_restore_test.go @@ -0,0 +1,218 @@ +//go:build unit + +package service + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +const nativeProviderChatToolJSON = `{"id":"cc_1","object":"chat.completion","model":"actual","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"id\":1}"}}]},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13,"prompt_tokens_details":{"cached_tokens":3}}}` + +func nativeProviderClientBody(protocol string, stream bool) []byte { + var fields string + switch protocol { + case APIProtocolResponses: + fields = `"input":[{"type":"function_call","call_id":"prior","name":"lookup","arguments":"{}"},{"type":"function_call_output","call_id":"prior","output":"result"}],"tools":[{"type":"function","name":"lookup","parameters":{"type":"object","properties":{"id":{"type":"integer"}}}}]` + case APIProtocolChatCompletions: + fields = `"messages":[{"role":"assistant","tool_calls":[{"id":"prior","type":"function","function":{"name":"lookup","arguments":"{}"}}]},{"role":"tool","tool_call_id":"prior","content":"result"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object","properties":{"id":{"type":"integer"}}}}}]` + case APIProtocolAnthropic: + fields = `"max_tokens":128,"messages":[{"role":"assistant","content":[{"type":"tool_use","id":"prior","name":"lookup","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"prior","content":"result"}]}],"tools":[{"name":"lookup","input_schema":{"type":"object","properties":{"id":{"type":"integer"}}}}]` + } + return []byte(fmt.Sprintf(`{"model":"alias","stream":%t,%s}`, stream, fields)) +} + +func nativeProviderChatToolSSE() string { + return "data: " + `{"id":"cc_1","object":"chat.completion.chunk","model":"actual","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"id\":1}"}}]},"finish_reason":null}]}` + "\n\n" + + "data: " + `{"id":"cc_1","object":"chat.completion.chunk","model":"actual","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13,"prompt_tokens_details":{"cached_tokens":3}}}` + "\n\ndata: [DONE]\n\n" +} + +func TestNativeProviderMessagesKeyRoutesPreserveModel(t *testing.T) { + const model = "claude-opus-4-6" + for _, platform := range []string{PlatformCline, PlatformCommandCode} { + t.Run(platform, func(t *testing.T) { + ctx := WithOpenAIMessagesKeyRoute(context.Background()) + group := &Group{ID: 1, Platform: platform, Status: StatusActive, Hydrated: true} + sanitizeGroupMessagesDispatchFields(group) + require.False(t, group.AllowMessagesDispatch) + require.True(t, openAIMessagesKeyRouteGroupAllowed(ctx, group, false)) + require.Empty(t, group.ResolveMessagesDispatchModel(model)) + require.Equal(t, model, openAIMessagesKeyRouteModel(ctx, group, model)) + for _, ids := range [][]int64{{1}, {1, 2}} { + id := int64(1) + key := &APIKey{GroupID: &id, Group: group, RouteGroupIDs: ids} + svc := &OpenAIGatewayService{} + routed, err := svc.hydrateAPIKeyGroup(ctx, key, id) + require.NoError(t, err) + require.Equal(t, platform, routed.Group.Platform) + } + composite := &Group{Platform: PlatformComposite} + resolved := WithResolvedTargetPlatform(ctx, platform) + require.True(t, openAIMessagesKeyRouteGroupAllowed(resolved, composite, false)) + require.Equal(t, model, openAIMessagesKeyRouteModel(resolved, composite, model)) + }) + } + ctx := WithOpenAIMessagesKeyRoute(context.Background()) + require.False(t, openAIMessagesKeyRouteGroupAllowed(ctx, &Group{Platform: PlatformOpenAI}, true)) +} + +func TestNativeProviderMessagesRoutesEnforceCandidatePermission(t *testing.T) { + const model = "claude-opus-4-6" + for _, platform := range []string{PlatformCline, PlatformCommandCode} { + t.Run(platform, func(t *testing.T) { + primary := &Group{ID: 1, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, CrossProtocolConversionEnabled: true} + fallback := &Group{ID: 2, Platform: platform, Status: StatusActive, Hydrated: true, CrossProtocolConversionEnabled: true} + accounts := []*Account{ + {ID: 11, Platform: PlatformOpenAI, GroupIDs: []int64{1}, Credentials: map[string]any{"model_mapping": map[string]any{"gpt-5.4": "gpt-5.4"}}}, + {ID: 12, Platform: platform, GroupIDs: []int64{2}, Credentials: map[string]any{"model_mapping": map[string]any{model: model}}}, + } + snapshot, _ := newSmartRouteCoreSnapshot([]*Group{primary, fallback}, accounts...) + svc := &OpenAIGatewayService{schedulerSnapshot: snapshot} + key := &APIKey{User: &User{}, GroupID: &primary.ID, Group: primary, RouteGroupIDs: []int64{1, 2}} + ctx := WithResolvedTargetPlatform(WithOpenAIMessagesKeyRoute(context.Background()), platform) + require.False(t, openAIMessagesKeyRouteGroupAllowed(ctx, primary, false)) + var tried []int64 + _, _, routed, err := svc.selectAlongKeyRoutes(ctx, key, []string{platform}, model, + func(_ context.Context, groupID *int64, _ []string, requestedModel string) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { + tried = append(tried, *groupID) + require.Equal(t, model, requestedModel) + return &AccountSelectionResult{Account: accounts[1]}, OpenAIAccountScheduleDecision{}, nil + }) + require.NoError(t, err) + require.Equal(t, []int64{2}, tried) + require.Equal(t, int64(2), *routed.GroupID) + }) + } +} + +func TestNativeProviderMessagesSingleCandidateRetainsTargetPermission(t *testing.T) { + const model = "grok-4.6" + group := &Group{ID: 1, Platform: PlatformAnthropic, Status: StatusActive, Hydrated: true, CrossProtocolConversionEnabled: true} + account := &Account{ID: 11, Platform: PlatformGrok, GroupIDs: []int64{1}, Credentials: map[string]any{"model_mapping": map[string]any{model: model}}} + snapshot, _ := newSmartRouteCoreSnapshot([]*Group{group}, account) + svc := &OpenAIGatewayService{schedulerSnapshot: snapshot} + key := &APIKey{User: &User{}, GroupID: &group.ID, Group: group} + ctx := WithResolvedTargetPlatform(WithOpenAIMessagesKeyRoute(context.Background()), PlatformGrok) + var tried []int64 + _, _, routed, err := svc.selectAlongKeyRoutes(ctx, key, []string{PlatformGrok}, model, + func(_ context.Context, groupID *int64, platforms []string, requestedModel string) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) { + tried = append(tried, *groupID) + require.Equal(t, model, requestedModel) + require.Equal(t, []string{PlatformGrok}, platforms) + return &AccountSelectionResult{Account: account}, OpenAIAccountScheduleDecision{}, nil + }) + require.NoError(t, err) + require.Equal(t, []int64{1}, tried) + require.Equal(t, group.ID, *routed.GroupID) +} + +func nativeProviderResponsesToolSSE() string { + return strings.Join([]string{ + `event: response.created`, `data: {"type":"response.created","response":{"id":"resp_1","model":"actual","status":"in_progress","output":[]}}`, "", + `event: response.output_item.added`, `data: {"type":"response.output_item.added","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"lookup","arguments":"","status":"in_progress"}}`, "", + `event: response.function_call_arguments.delta`, `data: {"type":"response.function_call_arguments.delta","output_index":0,"item_id":"fc_1","delta":"{\"id\":1}"}`, "", + `event: response.function_call_arguments.done`, `data: {"type":"response.function_call_arguments.done","output_index":0,"item_id":"fc_1","arguments":"{\"id\":1}"}`, "", + `event: response.output_item.done`, `data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"id\":1}","status":"completed"}}`, "", + `event: response.completed`, `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"actual","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"id\":1}","status":"completed"}],"usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13,"input_tokens_details":{"cached_tokens":3}}}}`, "", "", + }, "\n") +} + +// Native providers must carry tool history through their configured protocol +// in one HTTP request, with no model-name/host inference or protocol probing. +func TestNativeProviderToolRoundtripHTTP(t *testing.T) { + for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} { + for _, protocol := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { + if platform == PlatformZhipu && protocol == APIProtocolResponses { + continue + } + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/stream=%t", platform, protocol, stream), func(t *testing.T) { + type capture struct { + path string + body []byte + } + captures := make(chan capture, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(400) + return + } + captures <- capture{r.URL.Path, body} + wire, contentType := nativeProviderChatToolJSON, "application/json" + switch protocol { + case APIProtocolResponses: + wire = `{"id":"resp_1","object":"response","model":"actual","status":"completed","output":[{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{}"}],"usage":{"input_tokens":9,"output_tokens":4}}` + if stream { + wire, contentType = nativeProviderResponsesToolSSE(), "text/event-stream" + } + case APIProtocolAnthropic: + wire = `{"id":"msg_1","type":"message","role":"assistant","model":"actual","content":[{"type":"tool_use","id":"call_1","name":"lookup","input":{}}],"stop_reason":"tool_use","usage":{"input_tokens":9,"output_tokens":4}}` + if stream { + wire, contentType = toolAnthropicSSEStream(), "text/event-stream" + } + default: + if stream { + wire, contentType = nativeProviderChatToolSSE(), "text/event-stream" + } + } + w.Header().Set("Content-Type", contentType) + _, _ = io.WriteString(w, wire) + })) + defer server.Close() + account := &Account{ID: 900, Platform: platform, Type: AccountTypeAPIKey, Status: StatusActive, Schedulable: true, + Credentials: map[string]any{"api_key": "native-test-key", "api_protocol": APIProtocolAdaptive, + "api_base_urls": map[string]any{APIProtocolChatCompletions: server.URL + "/v1", APIProtocolResponses: server.URL + "/v1", APIProtocolAnthropic: server.URL + "/v1"}, + "model_mapping": map[string]any{"alias": "actual", "actual": "must-not-map-twice"}}} + body := nativeProviderClientBody(protocol, stream) + path := "/v1/chat/completions" + if protocol == APIProtocolResponses { + path = "/v1/responses" + } + if protocol == APIProtocolAnthropic { + path = "/v1/messages" + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) + c.Set("api_key", &APIKey{Group: &Group{Platform: platform, CrossProtocolConversionEnabled: false}}) + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: headerCancelUpstreamFunc(server.Client().Do)} + var result *OpenAIForwardResult + var err error + switch protocol { + case APIProtocolResponses: + result, err = svc.Forward(c.Request.Context(), c, account, body) + case APIProtocolAnthropic: + result, err = svc.ForwardAsAnthropic(c.Request.Context(), c, account, body, "", "") + default: + result, err = svc.ForwardAsChatCompletions(c.Request.Context(), c, account, body, "", "") + } + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, captures, 1) + request := <-captures + require.Equal(t, path, request.path) + require.Equal(t, "actual", gjson.GetBytes(request.body, "model").String()) + require.Contains(t, string(request.body), "prior") + require.Contains(t, string(request.body), "lookup") + require.Contains(t, rec.Body.String(), "lookup") + if protocol == APIProtocolResponses { + require.True(t, gjson.GetBytes(request.body, "input").Exists()) + } else { + require.True(t, gjson.GetBytes(request.body, "messages").Exists()) + } + }) + } + } + } +} diff --git a/backend/internal/service/openai_account_preauthorization_final_test.go b/backend/internal/service/openai_account_preauthorization_final_test.go index 35fb0e1..7179d45 100644 --- a/backend/internal/service/openai_account_preauthorization_final_test.go +++ b/backend/internal/service/openai_account_preauthorization_final_test.go @@ -115,15 +115,15 @@ func TestOpenAIFinalAttemptPricesExplicitEffortWithoutChangingScale(t *testing.T } } -func TestOpenAIFinalAttemptMixedNativeMaxMustFundBeforeNetwork(t *testing.T) { - for _, platform := range []string{PlatformMixed, PlatformCommandCode} { +func TestOpenAIFinalAttemptProviderNativeMaxMustFundBeforeNetwork(t *testing.T) { + for _, platform := range []string{PlatformCommandCode} { t.Run(platform, func(t *testing.T) { fixture := newPreauthorizationFixture() bs := NewBillingService(nil, nil) fixture.service.costCalculator = bs - group := &Group{ID: 9, Platform: PlatformMixed, ModelPricing: []ChannelModelPricing{{ + group := &Group{ID: 9, Platform: platform, RateMultiplier: 1, ModelPricing: []ChannelModelPricing{{ Models: []string{"claude-fable-5-1"}, MaxReasoningEffortMultiplier: float64Ptr(3), - }}, MixedProviderRateMultipliers: map[string]float64{"anthropic": 1}} + }}} key := &APIKey{ID: 7, UserID: 42, GroupID: &group.ID, Group: group} body := []byte(`{"model":"claude-fable-5-1","max_tokens":32,"messages":[{"role":"user","content":"hi"}],"output_config":{"effort":"max"}}`) svc := &OpenAIGatewayService{cfg: &config.Config{}, billingService: bs, resolver: NewModelPricingResolver(nil, bs)} @@ -157,7 +157,7 @@ func TestOpenAIFinalAttemptMixedNativeMaxMustFundBeforeNetwork(t *testing.T) { } func TestOpenAIFinalAttemptProviderChatMustFundBeforeNetwork(t *testing.T) { - for _, platform := range []string{PlatformMixed, PlatformCommandCode, PlatformCline} { + for _, platform := range []string{PlatformCommandCode, PlatformCline} { t.Run(platform, func(t *testing.T) { fixture := newPreauthorizationFixture() fixture.service.costCalculator = NewBillingService(nil, nil) diff --git a/backend/internal/service/openai_account_preauthorization_test.go b/backend/internal/service/openai_account_preauthorization_test.go index a892f86..6280b4e 100644 --- a/backend/internal/service/openai_account_preauthorization_test.go +++ b/backend/internal/service/openai_account_preauthorization_test.go @@ -39,8 +39,8 @@ func TestOpenAIAccountPreauthorizationUsesCredentialAndSentModel(t *testing.T) { require.NoError(t, err) ctx := ContextWithBalancePreauthorizationGuard(context.Background(), guard) svc := &OpenAIGatewayService{} - key := &APIKey{ID: 7, UserID: 42, GroupID: i64p(9), Group: &Group{ID: 9, Platform: PlatformMixed, - RateMultiplier: 9, MixedProviderRateMultipliers: map[string]float64{"openai": 0.15}}} + key := &APIKey{ID: 7, UserID: 42, GroupID: i64p(9), Group: &Group{ID: 9, Platform: PlatformOpenAI, + RateMultiplier: 0.15}} account := &Account{ID: 3, Platform: PlatformOpenAI, Type: tc.accountType, Credentials: map[string]any{"model_mapping": map[string]any{"channel-alias": tc.wireModel}}, Extra: map[string]any{openAILongContextBillingEnabledKey: true}} @@ -150,8 +150,8 @@ func TestOpenAIAccountPreauthorizationRebuildsRealPriceHoldAcrossCredentials(t * fixture := newPreauthorizationFixture() bs := NewBillingService(nil, nil) fixture.service.costCalculator = bs - group := &Group{ID: 9, Platform: PlatformMixed, LongContextPricingEnabled: true, - MixedProviderRateMultipliers: map[string]float64{"openai": 0.15}} + group := &Group{ID: 9, Platform: PlatformOpenAI, LongContextPricingEnabled: true, + RateMultiplier: 0.15} key := &APIKey{ID: 7, UserID: 42, GroupID: &group.ID, Group: group} svc := &OpenAIGatewayService{billingService: bs, resolver: NewModelPricingResolver(nil, bs)} body := []byte(`{"model":"gpt-6.1-sol","service_tier":"priority","max_output_tokens":16,"input":"` + strings.Repeat(" hello", 300001) + `"}`) diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 71d6afb..41230ca 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1977,9 +1977,6 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con if account == nil { return false, "account_nil" } - if !openAIAccountAllowsModelScope(ctx, account, req.RequestedModel, req.RequireCompact) { - return false, "model_scope_denied" - } if req.RequirePrivacySet && !account.IsPrivacySet() { return false, "privacy_not_set" } @@ -2805,11 +2802,6 @@ func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(account *Accoun if account == nil { return false } - for _, err := range observedErr { - if errors.Is(err, ErrModelScopeDenied) { - return false - } - } accountID := account.ID healthTripped := false if s != nil && s.rateLimitService != nil { @@ -2842,7 +2834,7 @@ func (s *OpenAIGatewayService) ReportOpenAIAccountScheduleResult(account *Accoun // ObserveOpenAIAccountHealthFailure records failures that cannot reach the // scheduler-result path, for example after semantic response bytes were sent. func (s *OpenAIGatewayService) ObserveOpenAIAccountHealthFailure(ctx context.Context, account *Account, observedErr error) bool { - if s == nil || s.rateLimitService == nil || account == nil || observedErr == nil || errors.Is(observedErr, ErrModelScopeDenied) { + if s == nil || s.rateLimitService == nil || account == nil || observedErr == nil { return false } return s.rateLimitService.ObserveOpenAIAPIKeyHealthFailure(ctx, account, observedErr) diff --git a/backend/internal/service/openai_gateway_cc_pipeline.go b/backend/internal/service/openai_gateway_cc_pipeline.go index 05872a7..816fcaa 100644 --- a/backend/internal/service/openai_gateway_cc_pipeline.go +++ b/backend/internal/service/openai_gateway_cc_pipeline.go @@ -16,7 +16,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/util/responseheaders" "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" "go.uber.org/zap" ) @@ -172,7 +171,7 @@ func (s *OpenAIGatewayService) openAIChatCompletionsTargetURL(account *Account) return "", fmt.Errorf("invalid cloudflare account id") } if baseURL == "" { - if account.IsMixed() || account.IsMultiProtocolAPIKey() || account.IsGeminiOpenAIProtocol() { + if account.IsMultiProtocolAPIKey() || account.IsGeminiOpenAIProtocol() { return "", fmt.Errorf("account requires an explicit Chat Completions endpoint") } baseURL = "https://api.openai.com" @@ -215,10 +214,6 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( userAgent string, grokCacheIdentity string, ) (*http.Response, error) { - if account.IsMixed() && c != nil { - c.Set(mixedCCAccountContextKey, account) - c.Set(mixedCCTargetContextKey, targetURL) - } upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx) upstreamReq, err := http.NewRequestWithContext(upstreamCtx, http.MethodPost, targetURL, bytes.NewReader(body)) releaseUpstreamCtx() @@ -266,11 +261,8 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest( if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.doOpenAITextUpstream(ctx, c, upstreamReq, proxyURL, account) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { - if account.IsMixed() && c.Request.Context().Err() != nil { - return nil, c.Request.Context().Err() - } return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } return resp, nil @@ -302,44 +294,24 @@ func (s *OpenAIGatewayService) scanCCStream( logPrefix string, requestID string, startTime time.Time, - mixed bool, emit func(*apicompat.ChatCompletionsChunk), ) ccStreamScanState { var st ccStreamScanState - sawFinishReason := false - sawOutput, sawPolicy := false, false - if upstreamResponseModelObserverFromContext(c) == nil { - beginUpstreamResponseModelObservation(c) - } - processPayload := func(payload, eventType string) bool { - payload = strings.TrimSpace(payload) - if payload == "" && (!mixed || !mixedCCPayloadIsError(nil, eventType)) { - return false + + scanner := s.newUpstreamSSEScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + payload, ok := extractOpenAISSEDataLine(line) + if !ok { + continue } - if mixed { - observeMixedCCPayload(c, []byte(payload), eventType) - if usage, ok := extractOpenAIUsageFromJSONBytes([]byte(payload)); ok { - st.Usage = usage - } - if mixedCCPayloadIsError([]byte(payload), eventType) || payload != "[DONE]" && !gjson.Valid(payload) { - kind := "upstream_error" - if !gjson.Valid(payload) && payload != "" { - kind = "invalid_json" - } - st.Err = newMixedCCFailure(c, []byte(payload), eventType, kind, sawOutput || sawPolicy || mixedCCPayloadHasOutput([]byte(payload)), nil) - return true - } + payload = strings.TrimSpace(payload) + if payload == "" { + continue } if payload == "[DONE]" { st.SawDone = true - if mixed && !sawOutput { - st.Err = newMixedCCFailure(c, nil, "", "empty_completion", sawPolicy, nil) - } - return true - } - if mixed && mixedCCUnsupportedConversionOutput([]byte(payload)) { - st.Err = newMixedCCFailure(c, nil, eventType, "unsupported_conversion_output", true, nil) - return true + break } // 观察上游 CC chunk 回显的 model / service_tier(计费以回显为准)。 // CC chunk 无 type 字段,按 untyped payload 观察(上游约束:只有终止 @@ -354,63 +326,17 @@ func (s *OpenAIGatewayService) scanCCStream( var chunk apicompat.ChatCompletionsChunk if err := json.Unmarshal([]byte(payload), &chunk); err != nil { - if mixed { - st.Err = newMixedCCFailure(c, nil, eventType, "invalid_chunk", sawOutput || sawPolicy, nil) - return true - } logger.L().Warn(logPrefix+": failed to parse chat stream chunk", zap.Error(err), zap.String("request_id", requestID), ) - return false + continue } - output := chatChunkStartsResponsesOutput(&chunk) - if mixed { - output = mixedCCPayloadHasOutput([]byte(payload)) - sawOutput = sawOutput || output - sawPolicy = sawPolicy || mixedCCPayloadHasPolicy([]byte(payload)) - } - if st.FirstTokenMs == nil && !isOpenAIChatUsageOnlyStreamChunk(payload) && output { + if st.FirstTokenMs == nil && !isOpenAIChatUsageOnlyStreamChunk(payload) && chatChunkStartsResponsesOutput(&chunk) { ms := int(time.Since(startTime).Milliseconds()) st.FirstTokenMs = &ms } - for _, choice := range gjson.Get(payload, "choices").Array() { - if strings.TrimSpace(choice.Get("finish_reason").String()) != "" { - sawFinishReason = true - } - } emit(&chunk) - return false - } - - scanner := s.newUpstreamSSEScanner(resp.Body) - var parser openAICompatSSEFrameParser - stopped := false - for scanner.Scan() { - line := scanner.Text() - if mixed { - frame, ok := parser.AddLine(line) - if !ok { - continue - } - stopped = processPayload(frame.Data, frame.EventType) - } else if payload, ok := extractOpenAISSEDataLine(line); ok { - stopped = processPayload(payload, "") - } - if stopped { - break - } - } - if mixed && !stopped { - if frame, ok := parser.Finish(); ok { - if scanner.Err() == nil { - processPayload(frame.Data, frame.EventType) - } else if usage, ok := extractOpenAIUsageFromJSONBytes([]byte(frame.Data)); ok { - // A read failure cannot release pending output or complete the - // request, but complete metering already received must survive. - st.Usage = usage - } - } } if err := scanner.Err(); err != nil { @@ -422,16 +348,6 @@ func (s *OpenAIGatewayService) scanCCStream( } st.Err = err } - if mixed && st.Err == nil && !st.SawDone && !sawFinishReason { - st.Err = ErrOpenAIUpstreamStreamTruncated - } - if mixed && st.Err == nil && !sawOutput { - st.Err = newMixedCCFailure(c, nil, "", "empty_completion", sawPolicy, nil) - } - if mixed && st.Err != nil { - st.Err = s.recordMixedCCFailure(c, resp, st.Err) - st.NonBillableUpstreamError = mixedCCErrorNonBillable(st.Err) - } return st } @@ -448,23 +364,8 @@ func (s *OpenAIGatewayService) readCCUpstreamJSONResponse( c *gin.Context, resp *http.Response, writeError compatErrorWriter, - mixed ...bool, ) (*apicompat.ChatCompletionsResponse, OpenAIUsage, error) { - var respBody []byte - var err error - if len(mixed) > 0 && mixed[0] { - var usage OpenAIUsage - respBody, usage, err = s.readMixedCCResponse(c, resp) - if err == nil && mixedCCUnsupportedConversionOutput(respBody) { - err = s.recordMixedCCFailure(c, resp, newMixedCCFailure(c, nil, "", "unsupported_conversion_output", true, nil)) - } - if err != nil { - writeError(c, http.StatusBadGateway, "api_error", "Upstream response failed before completion") - return nil, usage, err - } - } else { - respBody, err = ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) - } + respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) { writeError(c, http.StatusBadGateway, "api_error", "Failed to read upstream response") diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 6ae2714..978c3b6 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -59,13 +59,10 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { - return s.forwardWithProtocolFallback(ctx, c, account, body, APIProtocolChatCompletions, promptCacheKey, defaultMappedModel, func(attemptCtx context.Context) (*OpenAIForwardResult, error) { - return s.forwardOpenAIChatCompletions(attemptCtx, c, account, body, promptCacheKey, defaultMappedModel) - }) -} - -func (s *OpenAIGatewayService) forwardOpenAIChatCompletions(ctx context.Context, c *gin.Context, account *Account, body []byte, promptCacheKey, defaultMappedModel string) (*OpenAIForwardResult, error) { - clientCtx := ctx // The legacy drain context below must not authorize replay. + if account != nil && account.Platform == "mixed" { + return nil, fmt.Errorf("mixed has been retired; configure an explicit native platform") + } + clientCtx := ctx ctx = context.WithoutCancel(ctx) beginUpstreamResponseModelObservation(c) setCodexToolNameReverse(c, nil) @@ -126,16 +123,14 @@ func (s *OpenAIGatewayService) forwardOpenAIChatCompletions(ctx context.Context, } return s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, body, defaultMappedModel) case APIProtocolChatCompletions: - if !forcedOpenAIResponsesProtocol(ctx) { - if convertResponsesShape { - chatBody, err := s.convertResponsesShapedChatBody(body) - if err != nil { - return nil, err - } - return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel) + if convertResponsesShape { + chatBody, err := s.convertResponsesShapedChatBody(body) + if err != nil { + return nil, err } - return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) + return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel) } + return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } startTime := time.Now() @@ -152,7 +147,7 @@ func (s *OpenAIGatewayService) forwardOpenAIChatCompletions(ctx context.Context, // derive a stable seed from the final upstream model family. billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil && !account.IsMixed() { + if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } @@ -354,7 +349,7 @@ func (s *OpenAIGatewayService) forwardOpenAIChatCompletions(ctx context.Context, if account.ProxyID != nil && account.Proxy != nil { proxyURL = account.Proxy.URL() } - resp, err := s.doOpenAITextUpstream(ctx, c, upstreamReq, proxyURL, account) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { releaseUpstreamCtx() return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) @@ -372,7 +367,6 @@ func (s *OpenAIGatewayService) forwardOpenAIChatCompletions(ctx context.Context, openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportYes var respBody []byte var upstreamMsg string - var readErr error completeErrorBody := false if structuredInputRetryEligible && resp.StatusCode == http.StatusBadRequest { readCtx, cancelRead := context.WithCancel(clientCtx) @@ -383,17 +377,11 @@ func (s *OpenAIGatewayService) forwardOpenAIChatCompletions(ctx context.Context, respBody, upstreamMsg, completeErrorBody = s.readOpenAIUpstreamErrorWithCompleteness(readCtx, resp) cancelRead() } else { - respBody, upstreamMsg, readErr = s.readOpenAIProtocolError(resp, account) - } - if readErr != nil { - return nil, readErr + respBody, upstreamMsg = s.readOpenAIUpstreamError(resp) } legacyProtocolFallback := account.Type == AccountTypeAPIKey && !account.routesByModel() && openai_compat.ResolveResponsesSupport(account.Extra) == openai_compat.ResponsesSupportUnknown && !isResponsesEndpointSupportedByStatus(resp.StatusCode) - if fallbackErr := s.protocolRefusalFallback(ctx, c, account, APIProtocolResponses, resp.StatusCode, respBody, legacyProtocolFallback); fallbackErr != nil { - return nil, fallbackErr - } if !agentIdentityTaskRecoveryWasTried(ctx) && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, respBody) { expectedTaskID := account.GetCredential("task_id") if err := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); err != nil { @@ -401,7 +389,7 @@ func (s *OpenAIGatewayService) forwardOpenAIChatCompletions(ctx context.Context, } return s.ForwardAsChatCompletions(markAgentIdentityTaskRecoveryTried(ctx), c, account, body, promptCacheKey, defaultMappedModel) } - if openAIProtocolFallbackFromContext(ctx) == nil && legacyProtocolFallback { + if legacyProtocolFallback { logger.L().Info("openai chat_completions: /responses unsupported, falling back to raw chat completions", zap.Int64("account_id", account.ID), zap.Int("upstream_status", resp.StatusCode), @@ -448,7 +436,7 @@ func (s *OpenAIGatewayService) forwardOpenAIChatCompletions(ctx context.Context, // 计费 tier 优先采用上游回显值;上游未回显时回退到最终出站 body(经过 // fast policy filter/force 之后)里的 tier,policy filter 删掉字段后不再 // 按原请求 Fast 计费。 - if (handleErr == nil || account.IsMixed()) && result != nil { + if handleErr == nil && result != nil { if tier := resolvedOpenAIUpstreamServiceTier(c, extractOpenAIServiceTierFromBody(responsesBody)); tier != nil { result.ServiceTier = tier } @@ -535,14 +523,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse( ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") - finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, c, "openai chat_completions buffered", requestID, account.IsMixed()) - if account.IsMixed() && (err != nil || finalResponse == nil || strings.TrimSpace(finalResponse.Status) == "failed") { - if err == nil { - err = fmt.Errorf("upstream Responses stream failed or ended without terminal response") - } - writeMixedStreamFailure(c, APIProtocolChatCompletions, err) - return mixedPartialForwardResult(c, resp, usage, originalModel, billingModel, upstreamModel, false, startTime), err - } + finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, c, "openai chat_completions buffered", requestID) if err != nil { return nil, s.newOpenAICompatBufferedReadFailoverError(c, account, resp, requestID, upstreamModel, err) } @@ -792,11 +773,6 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( observer.ObserveOpenAI([]byte(payload), event.Type) refusalDetector.ObservePayload([]byte(payload)) s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage) - if account.IsMixed() && mixedPayloadIsError([]byte(payload), event.Type) { - streamNonFailoverErr = fmt.Errorf("upstream Responses stream failed") - writeMixedStreamFailure(c, APIProtocolChatCompletions, streamNonFailoverErr) - return true - } isTerminalEvent := isOpenAICompatResponsesTerminalEvent(event.Type) if isTerminalEvent { @@ -987,11 +963,6 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } if !clientDisconnected && !clientOutputStarted { if refusalDetector.IsSilentRefusal() { - if account.IsMixed() { - cause := fmt.Errorf("upstream Responses stream ended with an empty completion") - writeMixedStreamFailure(c, APIProtocolChatCompletions, cause) - return resultWithUsage(), cause - } return nil, newOpenAISilentRefusalFailoverError(c, account, requestID) } if len(pendingSSE) > 0 { @@ -1036,10 +1007,6 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } } missingTerminalErr := func() (*OpenAIForwardResult, error) { - if account.IsMixed() { - writeMixedStreamFailure(c, APIProtocolChatCompletions, ErrOpenAIUpstreamStreamTruncated) - return resultWithUsage(), ErrOpenAIUpstreamStreamTruncated - } return resultWithUsage(), fmt.Errorf("stream usage incomplete: missing terminal event") } processFrame := func(frame openAICompatSSEFrame) bool { @@ -1074,10 +1041,6 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } if err := scanner.Err(); err != nil { handleScanErr(err) - if account.IsMixed() { - writeMixedStreamFailure(c, APIProtocolChatCompletions, err) - return resultWithUsage(), err - } if clientDisconnected || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", err) } @@ -1153,10 +1116,6 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( } if ev.err != nil { handleScanErr(ev.err) - if account.IsMixed() { - writeMixedStreamFailure(c, APIProtocolChatCompletions, ev.err) - return resultWithUsage(), ev.err - } if clientDisconnected || errors.Is(ev.err, context.Canceled) || errors.Is(ev.err, context.DeadlineExceeded) { return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", ev.err) } @@ -1180,11 +1139,6 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse( if time.Since(lastRead) < streamInterval { continue } - if account.IsMixed() { - err := fmt.Errorf("stream data interval timeout") - writeMixedStreamFailure(c, APIProtocolChatCompletions, err) - return resultWithUsage(), err - } if clientDisconnected { return resultWithUsage(), fmt.Errorf("stream usage incomplete after timeout") } diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index 446e7bc..cd9de43 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -122,20 +122,14 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( if err := s.repriceOpenAIFinalAttempt(ctx, account, forwardedBody, billingModel, upstreamModel); err != nil { return nil, err } - resp, err := s.doOpenAITextUpstream(ctx, c, upstreamReq, proxyURL, account) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { - respBody, upstreamMsg, readErr := s.readOpenAIProtocolError(resp, account) - if readErr != nil { - return nil, readErr - } - if fallbackErr := s.protocolRefusalFallback(ctx, c, account, APIProtocolAnthropic, resp.StatusCode, respBody, false); fallbackErr != nil { - return nil, fallbackErr - } + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { return nil, foErr } @@ -147,9 +141,9 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( reasoningEffort = ApplyThinkingEnabledFallback(reasoningEffort, forwardedBody, upstreamModel) if clientStream { - return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, includeUsage, account.IsMixed()) + return s.handleCCStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, includeUsage) } - return s.handleCCBufferedFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, account.IsMixed()) + return s.handleCCBufferedFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) } // handleCCBufferedFromNativeAnthropic reads Anthropic SSE events, assembles the @@ -162,10 +156,8 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( upstreamModel string, reasoningEffort *string, startTime time.Time, - mixed ...bool, ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") - strictCompletion := len(mixed) > 0 && mixed[0] scanner := bufio.NewScanner(resp.Body) maxLineSize := defaultMaxLineSize @@ -176,12 +168,6 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( var finalResp *apicompat.AnthropicResponse var usage ClaudeUsage - failMixed := func(cause error) (*OpenAIForwardResult, error) { - writeMixedStreamFailure(c, APIProtocolChatCompletions, cause) - result := s.nativeAnthropicStreamResult(c, resp, &usage, nil, false, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) - result.Stream = false - return result, cause - } // 读间隔上限:上游挂住 SSE 时中止组装(缓冲路径尚未提交响应头,可回 502)。 streamInterval := s.anthropicNativeStreamInterval() @@ -198,9 +184,6 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( } onIdle := func() (*OpenAIForwardResult, error) { _ = resp.Body.Close() - if strictCompletion { - return failMixed(errAnthropicNativeStreamIdle) - } logger.L().Warn("openai cc via native anthropic buffered: data interval timeout", zap.String("request_id", requestID), zap.Duration("interval", streamInterval), @@ -212,9 +195,6 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( for { line, rerr := pump.next() if rerr != nil { - if strictCompletion { - return failMixed(mixedStreamReadFailure(rerr)) - } if errors.Is(rerr, errAnthropicNativeStreamIdle) { return onIdle() } @@ -223,16 +203,13 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( } // SSE 规范允许 `event:xxx`(冒号后无空格):Kimi 等 Anthropic 兼容上游 // 返回紧凑格式,严格匹配 "event: " 会丢弃全部事件(#4653 同根因)。 - eventType, ok := extractOpenAISSEEventLine(line) + _, ok := extractOpenAISSEEventLine(line) if !ok { continue } dataLine, rerr := pump.next() if rerr != nil { - if strictCompletion { - return failMixed(mixedStreamReadFailure(rerr)) - } if errors.Is(rerr, errAnthropicNativeStreamIdle) { return onIdle() } @@ -246,17 +223,8 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( var event apicompat.AnthropicStreamEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { - if strictCompletion { - return failMixed(fmt.Errorf("invalid upstream Anthropic event: %w", err)) - } continue } - if strictCompletion && mixedPayloadIsError([]byte(payload), eventType) { - if event.Usage != nil { - mergeAnthropicUsage(&usage, *event.Usage) - } - return failMixed(fmt.Errorf("upstream Anthropic stream failed")) - } if event.Type == "message_start" && event.Message != nil { finalResp = event.Message @@ -288,15 +256,9 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic( } } } - if strictCompletion && event.Type == "message_stop" { - break - } } if finalResp == nil { - if strictCompletion { - return failMixed(ErrOpenAIUpstreamStreamTruncated) - } writeChatCompletionsError(c, http.StatusBadGateway, "server_error", "Upstream stream ended without a response") return nil, fmt.Errorf("upstream stream ended without response") } @@ -350,10 +312,8 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( reasoningEffort *string, startTime time.Time, includeUsage bool, - mixed ...bool, ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") - strictCompletion := len(mixed) > 0 && mixed[0] if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -399,10 +359,6 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( ClientDisconnect: clientDisconnected, } } - failMixed := func(cause error) (*OpenAIForwardResult, error) { - writeMixedStreamFailure(c, APIProtocolChatCompletions, cause) - return resultWithUsage(), cause - } // 读间隔上限:上游挂住 SSE(不发数据也不断连)时结束排水。上游 ctx 为 // WithoutCancel 且 http.Client 无整体 Timeout,无此界限则客户端断开后 @@ -423,9 +379,6 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( // 返回——与 messages 主路径 "stream usage incomplete after timeout" 同语义。 onIdle := func() (*OpenAIForwardResult, error) { _ = resp.Body.Close() - if strictCompletion { - return failMixed(errAnthropicNativeStreamIdle) - } if !clientDisconnected { logger.L().Warn("openai cc via native anthropic stream: data interval timeout", zap.String("request_id", requestID), @@ -488,25 +441,19 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( for { line, rerr := pump.next() if rerr != nil { - if strictCompletion { - return failMixed(mixedStreamReadFailure(rerr)) - } if errors.Is(rerr, errAnthropicNativeStreamIdle) { return onIdle() } logReadErr(rerr) break } - eventType, ok := extractOpenAISSEEventLine(line) + _, ok := extractOpenAISSEEventLine(line) if !ok { continue } dataLine, rerr := pump.next() if rerr != nil { - if strictCompletion { - return failMixed(mixedStreamReadFailure(rerr)) - } if errors.Is(rerr, errAnthropicNativeStreamIdle) { return onIdle() } @@ -521,27 +468,12 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic( var event apicompat.AnthropicStreamEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { - if strictCompletion { - return failMixed(fmt.Errorf("invalid upstream Anthropic event: %w", err)) - } continue } - if strictCompletion && mixedPayloadIsError([]byte(payload), eventType) { - if event.Usage != nil { - mergeAnthropicUsage(&usage, *event.Usage) - } - return failMixed(fmt.Errorf("upstream Anthropic stream failed")) - } - if strictCompletion && event.Type == "message_stop" && !anthState.CreatedSent { - return failMixed(ErrOpenAIUpstreamStreamTruncated) - } if processAnthropicEvent(&event) { return resultWithUsage(), nil } - if strictCompletion && event.Type == "message_stop" { - break - } } // Finalize both state machines(客户端已断开时仍执行,保证 usage 汇总完整)。 diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index c0eec7b..8c7c640 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -86,7 +86,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletionsWithStartTime( // 2. Resolve model mapping (same as ForwardAsChatCompletions) billingModel := resolveOpenAIForwardModel(account, originalModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil && !account.IsMixed() { + if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } @@ -97,12 +97,12 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletionsWithStartTime( // anchored to the client's stable conversation prefix. grokCacheIdentity = resolveGrokCacheIdentity(c, body, "", upstreamModel) } - if !account.IsMixed() && openai.IsGPT61SolModelSpelling(upstreamModel) && (len(gjson.GetBytes(body, "tools").Array()) > 0 || len(gjson.GetBytes(body, "functions").Array()) > 0) { + if openai.IsGPT61SolModelSpelling(upstreamModel) && (len(gjson.GetBytes(body, "tools").Array()) > 0 || len(gjson.GetBytes(body, "functions").Array()) > 0) { err := fmt.Errorf("gpt-6.1-sol requires Responses for tool calls; this account only supports Chat Completions") writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } - if !account.IsMixed() && openai.IsGPT6SolOrLunaModelSpelling(upstreamModel) && (len(gjson.GetBytes(body, "tools").Array()) > 0 || len(gjson.GetBytes(body, "functions").Array()) > 0) && gjson.GetBytes(body, "reasoning_effort").String() != "none" { + if openai.IsGPT6SolOrLunaModelSpelling(upstreamModel) && (len(gjson.GetBytes(body, "tools").Array()) > 0 || len(gjson.GetBytes(body, "functions").Array()) > 0) && gjson.GetBytes(body, "reasoning_effort").String() != "none" { err := fmt.Errorf("%s requires Responses for tool calls with reasoning; this account only supports Chat Completions. Use reasoning_effort=none or a Responses-capable account", upstreamModel) writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err @@ -223,13 +223,7 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletionsWithStartTime( // 7. Handle error response with failover if resp.StatusCode >= 400 { - respBody, upstreamMsg, readErr := s.readOpenAIProtocolError(resp, account) - if readErr != nil { - return nil, readErr - } - if fallbackErr := s.protocolRefusalFallback(ctx, c, account, APIProtocolChatCompletions, resp.StatusCode, respBody, false); fallbackErr != nil { - return nil, fallbackErr - } + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) if account.Platform == PlatformGrok { kind := "http_error" if s.shouldFailoverGrokUpstreamError(resp.StatusCode, respBody) { @@ -332,8 +326,6 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( pendingLines := make([]string, 0, 8) refusalDetector := newOpenAIChatSilentRefusalDetector(requestBodyLen) var terminal openAIRawStreamTerminalState - var mixedStreamErr error - mixedSawOutput, mixedSawPolicy := false, false writeLine := func(line string) { if clientDisconnected { @@ -367,7 +359,8 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( } } - processLine := func(line string) { + for scanner.Scan() { + line := scanner.Text() refusalDetector.ObserveSSELine(line) if payload, ok := extractOpenAISSEDataLine(line); ok { trimmedPayload := strings.TrimSpace(payload) @@ -378,7 +371,7 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( if u := extractCCStreamUsage(payload); u != nil { usage = *u } - if firstTokenMs == nil && !usageOnlyChunk && (!account.IsMixed() || mixedCCPayloadHasOutput([]byte(payload))) { + if firstTokenMs == nil && !usageOnlyChunk { elapsed := int(time.Since(startTime).Milliseconds()) firstTokenMs = &elapsed } @@ -392,62 +385,13 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( if !clientDisconnected && clientOutputStarted { c.Writer.Flush() } - return - } - if !clientDisconnected && clientOutputStarted { - c.Writer.Flush() - } - } - processMixedFrame := func(frame openAICompatSSEFrame) bool { - payload := strings.TrimSpace(frame.Data) - observeMixedCCPayload(c, []byte(payload), frame.EventType) - if parsed, ok := extractOpenAIUsageFromJSONBytes([]byte(payload)); ok { - usage = parsed - } - if mixedCCPayloadIsError([]byte(payload), frame.EventType) || payload != "[DONE]" && !gjson.Valid(payload) { - kind := "upstream_error" - if !gjson.Valid(payload) && payload != "" { - kind = "invalid_json" - } - mixedStreamErr = newMixedCCFailure(c, []byte(payload), frame.EventType, kind, mixedSawOutput || mixedSawPolicy || mixedCCPayloadHasOutput([]byte(payload)), nil) - return false - } - mixedSawOutput = mixedSawOutput || mixedCCPayloadHasOutput([]byte(payload)) - mixedSawPolicy = mixedSawPolicy || mixedCCPayloadHasPolicy([]byte(payload)) - if payload == "[DONE]" && !mixedSawOutput { - mixedStreamErr = newMixedCCFailure(c, nil, "", "empty_completion", mixedSawPolicy, nil) - return false - } - if frame.EventType != "" { - processLine("event: " + frame.EventType) - } - // Valid JSON's physical newlines are whitespace, not string contents. - // Normalize multi-data frames so observer/detector see the full chunk. - processLine("data: " + strings.ReplaceAll(payload, "\n", "")) - processLine("") - return true - } - var mixedParser openAICompatSSEFrameParser - for scanner.Scan() { - line := scanner.Text() - if account.IsMixed() { - if frame, ok := mixedParser.AddLine(line); ok && !processMixedFrame(frame) { + if account != nil && account.IsGrok() && terminal.sawDone { break } continue } - processLine(line) - if line == "" && account.IsGrok() && terminal.sawDone { - break - } - } - if account.IsMixed() && mixedStreamErr == nil { - if frame, ok := mixedParser.Finish(); ok { - if scanner.Err() == nil { - processMixedFrame(frame) - } else if parsed, ok := extractOpenAIUsageFromJSONBytes([]byte(frame.Data)); ok { - usage = parsed - } + if !clientDisconnected && clientOutputStarted { + c.Writer.Flush() } } @@ -467,27 +411,10 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( Stream: true, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, - NonBillableUpstreamError: mixedCCErrorNonBillable(mixedStreamErr), } } scanErr := scanner.Err() - if account.IsMixed() { - if mixedStreamErr == nil { - mixedStreamErr = scanErr - } - if mixedStreamErr == nil && !terminal.sawDone && !terminal.sawFinishReason { - mixedStreamErr = ErrOpenAIUpstreamStreamTruncated - } - if mixedStreamErr == nil && !mixedSawOutput { - mixedStreamErr = newMixedCCFailure(c, nil, "", "empty_completion", mixedSawPolicy, nil) - } - if mixedStreamErr != nil { - mixedStreamErr = s.recordMixedCCFailure(c, resp, mixedStreamErr) - writeMixedStreamFailure(c, APIProtocolChatCompletions, mixedStreamErr) - return resultWithUsage(), newOpenAIUpstreamStreamReadError(mixedStreamErr) - } - } if scanErr != nil && !errors.Is(scanErr, context.Canceled) && !errors.Is(scanErr, context.DeadlineExceeded) { logger.L().Warn("openai chat_completions raw: stream read error", zap.Error(scanErr), @@ -526,11 +453,6 @@ func (s *OpenAIGatewayService) streamRawChatCompletions( if scanErr == nil && !clientDisconnected && !clientOutputStarted { if refusalDetector.IsSilentRefusal() { - if account.IsMixed() { - cause := fmt.Errorf("upstream Chat Completions stream ended with an empty completion") - writeMixedStreamFailure(c, APIProtocolChatCompletions, cause) - return resultWithUsage(), cause - } return nil, newOpenAISilentRefusalFailoverError(c, account, requestID) } if len(pendingLines) > 0 { @@ -605,20 +527,7 @@ func (s *OpenAIGatewayService) bufferRawChatCompletions( ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") - var respBody []byte - var err error - if account.IsMixed() { - var usage OpenAIUsage - respBody, usage, err = s.readMixedCCResponse(c, resp) - if err != nil { - writeMixedStreamFailure(c, APIProtocolChatCompletions, err) - result := mixedPartialForwardResult(c, resp, usage, originalModel, billingModel, upstreamModel, false, startTime) - result.NonBillableUpstreamError = mixedCCErrorNonBillable(err) - return result, err - } - } else { - respBody, err = ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) - } + respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { if !errors.Is(err, ErrUpstreamResponseBodyTooLarge) { writeChatCompletionsError(c, http.StatusBadGateway, "api_error", "Failed to read upstream response") diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 97548bc..217dc21 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -20,15 +20,12 @@ import ( // Forward forwards request to OpenAI API func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { - return s.forwardWithProtocolFallback(ctx, c, account, body, APIProtocolResponses, "", "", func(attemptCtx context.Context) (*OpenAIForwardResult, error) { - return s.forwardOpenAIResponses(attemptCtx, c, account, body) - }) -} - -func (s *OpenAIGatewayService) forwardOpenAIResponses(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) { + if account != nil && account.Platform == "mixed" { + return nil, fmt.Errorf("mixed has been retired; configure an explicit native platform") + } beginUpstreamResponseModelObservation(c) ClearActualOpenAIUpstreamEndpoint(c) - if shouldPreemptivelyConvertInboundResponsesToChat(account) && !forcedOpenAIResponsesProtocol(ctx) { + if shouldPreemptivelyConvertInboundResponsesToChat(account) { SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions") } filteredBody, filterErr := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body) @@ -146,7 +143,7 @@ func (s *OpenAIGatewayService) forwardOpenAIResponses(ctx context.Context, c *gi } nativeCNResponses := account.usesNativeResponsesUpstream() - nativeDeepSeekResponses := account.openAIWirePlatform() == PlatformDeepseek && nativeCNResponses + nativeDeepSeekResponses := account.Platform == PlatformDeepseek && nativeCNResponses if nativeDeepSeekResponses && account.Type == AccountTypeAPIKey && !compactPath && needsOpenAIResponsesClientToolAdaptation(body) { adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body) @@ -193,9 +190,7 @@ func (s *OpenAIGatewayService) forwardOpenAIResponses(ctx context.Context, c *gi } return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel) case APIProtocolChatCompletions: - if !forcedOpenAIResponsesProtocol(ctx) { - return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) - } + return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) } if account.IsOpenAI() && (account.IsOpenAIApiKey() || account.IsOpenAIOAuthLike()) { normalizedReasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningContentReplay(body) @@ -588,8 +583,8 @@ func (s *OpenAIGatewayService) forwardOpenAIResponses(ctx context.Context, c *gi if !isCodexCLI { maxOutputTokens := requestRoot.Get("max_output_tokens") if maxOutputTokens.Exists() { - switch account.openAIWirePlatform() { - case PlatformOpenAI, PlatformDeepseek, PlatformMixed: + switch account.Platform { + case PlatformOpenAI, PlatformDeepseek: // Preserve Responses-native output limits unless the selected upstream // explicitly rejects the field in the bounded HTTP retry loop below. case PlatformAnthropic: @@ -1143,16 +1138,7 @@ func (s *OpenAIGatewayService) forwardOpenAIResponses(ctx context.Context, c *gi _ = resp.Body.Close() return nil, clientErr } - var respBody []byte - if account.IsMixed() { - var readErr error - respBody, _, readErr = s.readOpenAIProtocolError(resp, account) - if readErr != nil { - return nil, readErr - } - } else { - respBody = s.readUpstreamErrorBody(resp) - } + respBody := s.readUpstreamErrorBody(resp) _ = resp.Body.Close() if clientErr := clientCtx.Err(); clientErr != nil { return nil, clientErr @@ -1162,16 +1148,6 @@ func (s *OpenAIGatewayService) forwardOpenAIResponses(ctx context.Context, c *gi upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) upstreamCode := extractUpstreamErrorCode(respBody) - if fallbackErr := s.protocolRefusalFallback(ctx, c, account, APIProtocolResponses, resp.StatusCode, respBody, - shouldFallbackOpenAIResponsesToChatOnUnsupportedEndpoint(account, c, resp.StatusCode)); fallbackErr != nil { - return nil, fallbackErr - } - if account.IsMixed() { - if failoverErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); failoverErr != nil { - return nil, failoverErr - } - return s.handleErrorResponse(ctx, resp, c, account, body, resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel)) - } if !responsesLiteParallelToolCallsRetryTried && resp.StatusCode == http.StatusBadRequest && isOpenAIResponsesLiteParallelToolCallsError(respBody) { @@ -1256,7 +1232,7 @@ func (s *OpenAIGatewayService) forwardOpenAIResponses(ctx context.Context, c *gi ) continue } - if openAIProtocolFallbackFromContext(ctx) == nil && shouldFallbackOpenAIResponsesToChatOnUnsupportedEndpoint(account, c, resp.StatusCode) { + if shouldFallbackOpenAIResponsesToChatOnUnsupportedEndpoint(account, c, resp.StatusCode) { logger.LegacyPrintf( "service.openai_gateway", "[OpenAI] /v1/responses unsupported, falling back to /v1/chat/completions (account: %s, status: %d)", @@ -1407,20 +1383,11 @@ func (s *OpenAIGatewayService) forwardOpenAIResponses(ctx context.Context, c *gi } return s.handleErrorResponse(ctx, compactResp, c, account, body, resolveOpenAIErrorSchedulingModel(billingModel, upstreamModel)) } - if account.IsMixed() { - return buildForwardResult(), err - } return preserveOpenAIStreamingResultOnError(buildForwardResult(), err) } } else { nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel) if err != nil { - if account.IsMixed() { - if nonStreamResult != nil { - usage = nonStreamResult.usage - } - return buildForwardResult(), err - } if clientErr := clientCtx.Err(); clientErr != nil { return nil, clientErr } @@ -1604,7 +1571,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses) } if baseURL == "" { - if account.IsMixed() || account.IsMultiProtocolAPIKey() || account.IsGeminiOpenAIProtocol() { + if account.IsMultiProtocolAPIKey() || account.IsGeminiOpenAIProtocol() { return nil, fmt.Errorf("account requires an explicit Responses endpoint") } targetURL = openaiPlatformAPIURL @@ -1613,7 +1580,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. if err != nil { return nil, err } - targetURL = buildOpenAIResponsesURLForPlatform(account.openAIWirePlatform(), validatedURL) + targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) } default: targetURL = openaiPlatformAPIURL diff --git a/backend/internal/service/openai_gateway_header_cancel.go b/backend/internal/service/openai_gateway_header_cancel.go index e6488b0..87e5cfc 100644 --- a/backend/internal/service/openai_gateway_header_cancel.go +++ b/backend/internal/service/openai_gateway_header_cancel.go @@ -11,9 +11,6 @@ import ( // Only bound the header wait after the original client cancels. Once headers // arrive, the existing response handlers own draining and body cleanup. func (s *OpenAIGatewayService) doOpenAIUpstreamWithHeaderCancel(clientCtx context.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, error) { - if account.IsMixed() { - return s.doOpenAITextUpstream(clientCtx, nil, request, proxyURL, account) - } if clientCtx == nil || clientCtx.Done() == nil { return s.doOpenAIUpstream(request, proxyURL, account) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 86fb535..d14d235 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -33,12 +33,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { - return s.forwardWithProtocolFallback(ctx, c, account, body, APIProtocolAnthropic, promptCacheKey, defaultMappedModel, func(attemptCtx context.Context) (*OpenAIForwardResult, error) { - return s.forwardOpenAIAnthropic(attemptCtx, c, account, body, promptCacheKey, defaultMappedModel) - }) -} - -func (s *OpenAIGatewayService) forwardOpenAIAnthropic(ctx context.Context, c *gin.Context, account *Account, body []byte, promptCacheKey, defaultMappedModel string) (*OpenAIForwardResult, error) { + if account != nil && account.Platform == "mixed" { + return nil, fmt.Errorf("mixed has been retired; configure an explicit native platform") + } // Preserve the existing platform-scoped repairs for invalid tool schemas. // This is shared validation, not protocol conversion; valid schemas and // vendor-specific request fields remain unchanged on the native path. @@ -71,7 +68,7 @@ func (s *OpenAIGatewayService) forwardOpenAIAnthropic(ctx context.Context, c *gi // 固定 chat_completions 的 CN 账号,以及不支持 Responses 的其他 APIKey // 账号,均将 Messages 转为 CC;固定 responses 的 CN 账号不受探针旧值覆盖。 - if upstreamProtocol == APIProtocolChatCompletions && !forcedOpenAIResponsesProtocol(ctx) { + if upstreamProtocol == APIProtocolChatCompletions { SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions") return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel) } @@ -85,16 +82,14 @@ func (s *OpenAIGatewayService) forwardOpenAIAnthropic(ctx context.Context, c *gi } anthropicDigestReq := cloneAnthropicRequestForDigest(&anthropicReq) originalModel := anthropicReq.Model - if !account.IsMixed() { - applyOpenAICompatModelNormalization(&anthropicReq) - } + applyOpenAICompatModelNormalization(&anthropicReq) normalizedModel := anthropicReq.Model clientStream := anthropicReq.Stream // client's original stream preference // 2. Model mapping billingModel := resolveOpenAIForwardModel(account, normalizedModel, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil && !account.IsMixed() { + if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } @@ -426,7 +421,7 @@ func (s *OpenAIGatewayService) forwardOpenAIAnthropic(ctx context.Context, c *gi if err := s.repriceOpenAIFinalHTTPRequest(ctx, account, upstreamReq, billingModel, upstreamModel); err != nil { return nil, err } - resp, err = s.doOpenAITextUpstream(ctx, c, upstreamReq, proxyURL, account) + resp, err = s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, false) } @@ -465,13 +460,7 @@ func (s *OpenAIGatewayService) forwardOpenAIAnthropic(ctx context.Context, c *gi // 8. Handle error response with failover if resp.StatusCode >= 400 { - respBody, upstreamMsg, readErr := s.readOpenAIProtocolError(resp, account) - if readErr != nil { - return nil, readErr - } - if fallbackErr := s.protocolRefusalFallback(ctx, c, account, APIProtocolResponses, resp.StatusCode, respBody, false); fallbackErr != nil { - return nil, fallbackErr - } + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) if !agentIdentityTaskRecoveryWasTried(ctx) && s.isAgentIdentityAccount(ctx, account) && isAgentIdentityTaskInvalidHTTPResponse(resp.StatusCode, respBody) { expectedTaskID := account.GetCredential("task_id") if err := s.recoverAgentIdentityTask(ctx, account, expectedTaskID); err != nil { @@ -542,7 +531,7 @@ func (s *OpenAIGatewayService) forwardOpenAIAnthropic(ctx context.Context, c *gi } // Propagate ServiceTier and ReasoningEffort to result for billing - if (handleErr == nil || account.IsMixed()) && result != nil { + if handleErr == nil && result != nil { if compatContinuationEnabled && promptCacheKey != "" && result.ResponseID != "" { s.bindOpenAICompatSessionResponseID(ctx, c, account, promptCacheKey, result.ResponseID) } @@ -611,14 +600,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") - finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, c, "openai messages buffered", requestID, account.IsMixed()) - if account.IsMixed() && (err != nil || finalResponse == nil || strings.TrimSpace(finalResponse.Status) == "failed") { - if err == nil { - err = fmt.Errorf("upstream Responses stream failed or ended without terminal response") - } - writeMixedStreamFailure(c, APIProtocolAnthropic, err) - return mixedPartialForwardResult(c, resp, usage, originalModel, billingModel, upstreamModel, false, startTime), err - } + finalResponse, usage, acc, err := s.readOpenAICompatBufferedTerminal(resp, c, "openai messages buffered", requestID) if err != nil { var readErr *openAICompatBufferedReadError if errors.As(err, &readErr) && readErr != nil { @@ -790,10 +772,8 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( c *gin.Context, logPrefix string, requestID string, - mixed ...bool, ) (*apicompat.ResponsesResponse, OpenAIUsage, *apicompat.BufferedResponseAccumulator, error) { acc := apicompat.NewBufferedResponseAccumulator() - strictCompletion := len(mixed) > 0 && mixed[0] var usage OpenAIUsage if resp == nil || resp.Body == nil { return nil, usage, acc, errors.New("upstream response body is nil") @@ -873,9 +853,6 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( var event apicompat.ResponsesStreamEvent if err := json.Unmarshal([]byte(payload), &event); err == nil { s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage) - if strictCompletion && mixedPayloadIsError([]byte(payload), event.Type) { - return nil, usage, acc, fmt.Errorf("upstream Responses stream failed") - } acc.ProcessEvent(&event) if response := openAICompatTerminalResponse(&event, []byte(payload)); isOpenAICompatResponsesTerminalEvent(event.Type) && response != nil { if event.Usage != nil { @@ -923,9 +900,6 @@ func (s *OpenAIGatewayService) readOpenAICompatBufferedTerminal( continue } s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage) - if strictCompletion && mixedPayloadIsError([]byte(payload), event.Type) { - return nil, usage, acc, fmt.Errorf("upstream Responses stream failed") - } acc.ProcessEvent(&event) @@ -1058,11 +1032,6 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( } observer.ObserveOpenAI([]byte(payload), event.Type) s.parseSSEUsageBytesWithType([]byte(payload), event.Type, &usage) - if account.IsMixed() && mixedPayloadIsError([]byte(payload), event.Type) { - streamNonFailoverErr = fmt.Errorf("upstream Responses stream failed") - writeMixedStreamFailure(c, APIProtocolAnthropic, streamNonFailoverErr) - return true - } eventType := strings.TrimSpace(event.Type) isBareErrorEvent := eventType == "error" @@ -1222,10 +1191,6 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( } missingTerminalErr := func() (*OpenAIForwardResult, error) { result := resultWithUsage() - if account.IsMixed() { - writeMixedStreamFailure(c, APIProtocolAnthropic, ErrOpenAIUpstreamStreamTruncated) - return result, ErrOpenAIUpstreamStreamTruncated - } if clientDisconnected { return result, fmt.Errorf("stream usage incomplete: missing terminal event") } @@ -1265,10 +1230,6 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( } if err := scanner.Err(); err != nil { handleScanErr(err) - if account.IsMixed() { - writeMixedStreamFailure(c, APIProtocolAnthropic, err) - return resultWithUsage(), err - } return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", err) } if frame, ok := parser.Finish(); ok { @@ -1342,10 +1303,6 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( } if ev.err != nil { handleScanErr(ev.err) - if account.IsMixed() { - writeMixedStreamFailure(c, APIProtocolAnthropic, ev.err) - return resultWithUsage(), ev.err - } return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", ev.err) } lastDataAt = time.Now() @@ -1366,11 +1323,6 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( if time.Since(lastRead) < streamInterval { continue } - if account.IsMixed() { - err := fmt.Errorf("stream data interval timeout") - writeMixedStreamFailure(c, APIProtocolAnthropic, err) - return resultWithUsage(), err - } if clientDisconnected { return resultWithUsage(), fmt.Errorf("stream usage incomplete after timeout") } diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index 1cccbfc..538a735 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -24,7 +24,6 @@ import ( "sync/atomic" "time" - "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/gin-gonic/gin" "github.com/tidwall/gjson" @@ -102,20 +101,14 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( if err := s.repriceOpenAIFinalAttempt(ctx, account, forwardedBody, billingModel, upstreamModel); err != nil { return nil, err } - resp, err := s.doOpenAITextUpstream(ctx, c, upstreamReq, proxyURL, account) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { - respBody, upstreamMsg, readErr := s.readOpenAIProtocolError(resp, account) - if readErr != nil { - return nil, readErr - } - if fallbackErr := s.protocolRefusalFallback(ctx, c, account, APIProtocolAnthropic, resp.StatusCode, respBody, false); fallbackErr != nil { - return nil, fallbackErr - } + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { return nil, foErr } @@ -134,13 +127,6 @@ func (s *OpenAIGatewayService) forwardAnthropicViaNativeAnthropicEndpoint( // 第三方端点保持朴素路径,不附加 ?beta=true。 func (s *OpenAIGatewayService) nativeAnthropicTargetURL(account *Account) (string, error) { baseURL := strings.TrimSpace(account.GetAnthropicProtocolBaseURL()) - if baseURL == "" && genericOpenAIProtocolFallbackAccount(account) { - var err error - baseURL, err = genericOpenAIAnthropicBaseURL(account) - if err != nil { - return "", err - } - } if baseURL == "" { return "", fmt.Errorf("account %d has no anthropic protocol base url", account.ID) } @@ -148,9 +134,6 @@ func (s *OpenAIGatewayService) nativeAnthropicTargetURL(account *Account) (strin if err != nil { return "", fmt.Errorf("invalid base_url: %w", err) } - if genericOpenAIProtocolFallbackAccount(account) { - return buildOpenAIEndpointURL(validatedURL, "/v1/messages"), nil - } u, err := url.Parse(validatedURL) if err != nil { return "", fmt.Errorf("invalid base_url: %w", err) @@ -224,9 +207,6 @@ func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest( req.Header.Del("x-goog-api-key") req.Header.Del("cookie") authBaseURL := account.GetAnthropicProtocolBaseURL() - if genericOpenAIProtocolFallbackAccount(account) { - authBaseURL, _ = genericOpenAIAnthropicBaseURL(account) - } setAnthropicAPIKeyAuthHeader(req.Header, account, apiKey, authBaseURL) if getHeaderRaw(req.Header, "content-type") == "" { @@ -260,23 +240,7 @@ func (s *OpenAIGatewayService) handleNativeAnthropicBufferedResponse( s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header) } - var body []byte - var err error - if account.IsMixed() { - body, err = s.readMixedUpstreamBody(resp) - usage := parseClaudeUsageFromResponseBody(body) - if err == nil && (!gjson.ValidBytes(body) || mixedPayloadIsError(body, "") || gjson.GetBytes(body, "type").String() != "message" || strings.TrimSpace(gjson.GetBytes(body, "stop_reason").String()) == "") { - err = fmt.Errorf("upstream Anthropic response failed or incomplete") - } - if err != nil { - writeMixedStreamFailure(c, APIProtocolAnthropic, err) - result := s.nativeAnthropicStreamResult(c, resp, usage, nil, false, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) - result.Stream = false - return result, err - } - } else { - body, err = ReadUpstreamResponseBody(resp.Body, s.cfg, c, anthropicTooLargeError) - } + body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, anthropicTooLargeError) if err != nil { return nil, err } @@ -308,17 +272,6 @@ func (s *OpenAIGatewayService) handleNativeAnthropicBufferedResponse( contentType = "application/json" } body = reverseToolNamesIfPresent(c, body) - if account.IsMixed() && gjson.GetBytes(body, "stop_reason").String() == "end_turn" { - for _, block := range gjson.GetBytes(body, "content").Array() { - if block.Get("type").String() == "tool_use" { - body, err = sjson.SetBytes(body, "stop_reason", "tool_use") - if err != nil { - return nil, err - } - break - } - } - } c.Data(resp.StatusCode, contentType, body) return &OpenAIForwardResult{ @@ -385,7 +338,6 @@ func (s *OpenAIGatewayService) handleNativeAnthropicStreamingResponse( var firstTokenMs *int clientDisconnected := false sawTerminalEvent := false - sawMixedToolUse := false pendingErrorHeader := "" scanner := bufio.NewScanner(newUpstreamErrorFrameReader(resp.Body, resolveUpstreamResponseReadLimit(s.cfg))) @@ -468,18 +420,11 @@ func (s *OpenAIGatewayService) handleNativeAnthropicStreamingResponse( keepaliveTimer.Reset(keepaliveInterval) } inPartialEvent := false - failMixed := func(cause error) (*OpenAIForwardResult, error) { - writeMixedStreamFailure(c, APIProtocolAnthropic, cause) - return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), cause - } for { select { case ev, ok := <-events: if !ok { - if account.IsMixed() && !sawTerminalEvent { - return failMixed(ErrOpenAIUpstreamStreamTruncated) - } if !clientDisconnected { flusher.Flush() } @@ -490,9 +435,6 @@ func (s *OpenAIGatewayService) handleNativeAnthropicStreamingResponse( return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), nil } if ev.err != nil { - if account.IsMixed() && !sawTerminalEvent { - return failMixed(mixedStreamReadFailure(ev.err)) - } if sawTerminalEvent { return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), nil } @@ -513,39 +455,12 @@ func (s *OpenAIGatewayService) handleNativeAnthropicStreamingResponse( } line := ev.line - eventType, eventLine := extractOpenAISSEEventLine(line) - if strings.TrimSpace(line) == "event: error" || account.IsMixed() && eventLine && eventType == "error" { + if strings.TrimSpace(line) == "event: error" { pendingErrorHeader = line continue } if data, ok := extractAnthropicSSEDataLine(line); ok { trimmed := strings.TrimSpace(data) - if account.IsMixed() { - parseSSEUsagePassthrough(data, usage) - if mixedPayloadIsError([]byte(data), "") || pendingErrorHeader != "" { - // An error event may be the only event carrying usage. - var event apicompat.AnthropicStreamEvent - if json.Unmarshal([]byte(data), &event) == nil && event.Usage != nil { - mergeAnthropicUsage(usage, *event.Usage) - } - return failMixed(fmt.Errorf("upstream Anthropic stream failed")) - } - if gjson.Get(data, "content_block.type").String() == "tool_use" { - sawMixedToolUse = true - } - for _, block := range gjson.Get(data, "message.content").Array() { - if block.Get("type").String() == "tool_use" { - sawMixedToolUse = true - } - } - if sawMixedToolUse && gjson.Get(data, "delta.stop_reason").String() == "end_turn" { - normalized, normErr := sjson.Set(data, "delta.stop_reason", "tool_use") - if normErr != nil { - return nil, normErr - } - data, trimmed, line = normalized, normalized, "data: "+normalized - } - } if upstreamFinancialFailureEnvelope([]byte(data)) || pendingErrorHeader != "" && IsUpstreamFinancialError(0, []byte(data)) { setOpsUpstreamError(c, http.StatusBadGateway, extractUpstreamErrorMessage([]byte(data)), "") if !clientDisconnected { @@ -554,7 +469,7 @@ func (s *OpenAIGatewayService) handleNativeAnthropicStreamingResponse( return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), nil } observer.ObserveAnthropic([]byte(trimmed)) - if account.IsMixed() && gjson.Valid(trimmed) && gjson.Get(trimmed, "type").String() == "message_stop" || !account.IsMixed() && anthropicStreamEventIsTerminal("", trimmed) { + if anthropicStreamEventIsTerminal("", trimmed) { sawTerminalEvent = true } if firstTokenMs == nil && trimmed != "" && trimmed != "[DONE]" { @@ -564,7 +479,7 @@ func (s *OpenAIGatewayService) handleNativeAnthropicStreamingResponse( parseSSEUsagePassthrough(data, usage) } else { trimmed := strings.TrimSpace(line) - if !account.IsMixed() && strings.HasPrefix(trimmed, "event:") && anthropicStreamEventIsTerminal(strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")), "") { + if strings.HasPrefix(trimmed, "event:") && anthropicStreamEventIsTerminal(strings.TrimSpace(strings.TrimPrefix(trimmed, "event:")), "") { sawTerminalEvent = true } } @@ -597,9 +512,6 @@ func (s *OpenAIGatewayService) handleNativeAnthropicStreamingResponse( if time.Since(lastRead) < streamInterval { continue } - if account.IsMixed() { - return failMixed(fmt.Errorf("stream data interval timeout")) - } if clientDisconnected { return s.nativeAnthropicStreamResult(c, resp, usage, firstTokenMs, clientDisconnected, originalModel, billingModel, upstreamModel, reasoningEffort, startTime), fmt.Errorf("stream usage incomplete after timeout") diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 1a6e601..3c367fb 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -48,9 +48,7 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", "model is required") return nil, fmt.Errorf("missing model in request") } - if !account.IsMixed() { - applyOpenAICompatModelNormalization(&anthropicReq) - } + applyOpenAICompatModelNormalization(&anthropicReq) clientStream := anthropicReq.Stream // 2. Anthropic → Chat Completions (direct, no Responses intermediary) @@ -65,11 +63,11 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( billingModel := resolveOpenAIForwardModel(account, anthropicReq.Model, defaultMappedModel) upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil && !account.IsMixed() { + if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } - if !account.IsMixed() && openai.IsGPT61SolModelSpelling(upstreamModel) && len(anthropicReq.Tools) > 0 { + if openai.IsGPT61SolModelSpelling(upstreamModel) && len(anthropicReq.Tools) > 0 { err := fmt.Errorf("gpt-6.1-sol requires Responses for tool calls; this account only supports Chat Completions") writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err @@ -133,13 +131,7 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( // 4. Handle error responses if resp.StatusCode >= 400 { - respBody, upstreamMsg, readErr := s.readOpenAIProtocolError(resp, account) - if readErr != nil { - return nil, readErr - } - if fallbackErr := s.protocolRefusalFallback(ctx, c, account, APIProtocolChatCompletions, resp.StatusCode, respBody, false); fallbackErr != nil { - return nil, fallbackErr - } + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { return nil, foErr } @@ -150,9 +142,9 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( // 5. Convert response if clientStream { - return s.streamChatCompletionsAsAnthropic(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime, account.IsMixed()) + return s.streamChatCompletionsAsAnthropic(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } - return s.bufferChatCompletionsAsAnthropic(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime, account.IsMixed()) + return s.bufferChatCompletionsAsAnthropic(c, resp, originalModel, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic( @@ -164,16 +156,10 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsAnthropic( reasoningEffort *string, serviceTier *string, startTime time.Time, - mixed ...bool, ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") - ccResp, usage, err := s.readCCUpstreamJSONResponse(c, resp, writeAnthropicError, mixed...) + ccResp, usage, err := s.readCCUpstreamJSONResponse(c, resp, writeAnthropicError) if err != nil { - if len(mixed) > 0 && mixed[0] { - result := mixedPartialForwardResult(c, resp, usage, originalModel, billingModel, upstreamModel, false, startTime) - result.NonBillableUpstreamError = mixedCCErrorNonBillable(err) - return result, err - } return nil, err } anthropicResp := apicompat.ChatCompletionsResponseToAnthropic(ccResp, originalModel) @@ -210,9 +196,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( reasoningEffort *string, serviceTier *string, startTime time.Time, - mixedAccount ...bool, ) (*OpenAIForwardResult, error) { - mixed := len(mixedAccount) > 0 && mixedAccount[0] requestID := resp.Header.Get("x-request-id") writeStreamHeaders := s.newStreamHeaderWriter(c, resp.Header) @@ -243,13 +227,10 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsAnthropic( } } - scan := s.scanCCStream(c, resp, "openai messages chat fallback", requestID, startTime, mixed, emitChunk) + scan := s.scanCCStream(c, resp, "openai messages chat fallback", requestID, startTime, emitChunk) usage := scan.Usage if scan.Err != nil { - if mixed { - writeMixedStreamFailure(c, APIProtocolAnthropic, scan.Err) - } // Broken upstream read: skip finalization so no synthetic message_stop // masks the truncation, and surface the error to flag usage incomplete // (mirrors forwardResponsesViaRawChatCompletions). diff --git a/backend/internal/service/openai_gateway_mixed_safety.go b/backend/internal/service/openai_gateway_mixed_safety.go deleted file mode 100644 index 895f60e..0000000 --- a/backend/internal/service/openai_gateway_mixed_safety.go +++ /dev/null @@ -1,196 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "sync" - "time" - - "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" -) - -// Mixed has no disconnected-client drain/replay budget. Bind every header and -// body read to the original client, even when a legacy builder detached it. -func (s *OpenAIGatewayService) doOpenAITextUpstream(ctx context.Context, c *gin.Context, request *http.Request, proxyURL string, account *Account) (*http.Response, error) { - if !account.IsMixed() { - return s.doOpenAIUpstream(request, proxyURL, account) - } - clientCtx := ctx - if c != nil && c.Request != nil { - clientCtx = c.Request.Context() - } - upstreamCtx, cancel := context.WithCancel(WithHTTPUpstreamProfile(WithHTTPUpstreamRedirectsDisabled(ctx), HTTPUpstreamProfileOpenAI)) - stopClient := context.AfterFunc(clientCtx, cancel) - if clientCtx.Err() != nil { - cancel() - } - resp, err := s.doOpenAIUpstream(request.WithContext(upstreamCtx), proxyURL, account) - if err != nil || upstreamCtx.Err() != nil { - stopClient() - cancel() - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - if clientCtx.Err() != nil { - return nil, clientCtx.Err() - } - if ctx.Err() != nil { - return nil, ctx.Err() - } - return nil, err - } - body := &mixedCancelableBody{ReadCloser: resp.Body, ctx: upstreamCtx, cancel: cancel, stopClient: stopClient} - context.AfterFunc(upstreamCtx, func() { _ = body.Close() }) - resp.Body = body - return resp, nil -} - -type mixedCancelableBody struct { - io.ReadCloser - ctx context.Context - cancel context.CancelFunc - stopClient func() bool - once sync.Once -} - -func (b *mixedCancelableBody) Read(p []byte) (int, error) { - n, err := b.ReadCloser.Read(p) - if b.ctx.Err() != nil { - err = b.ctx.Err() - } - return n, err -} - -func (b *mixedCancelableBody) Close() error { - var err error - b.once.Do(func() { - b.stopClient() - err = b.ReadCloser.Close() - b.cancel() - }) - return err -} - -// Complete explicit refusal evidence is required before replay. Unlike legacy -// diagnostic reads, Mixed propagates read errors and rejects oversized bodies. -func (s *OpenAIGatewayService) readOpenAIProtocolError(resp *http.Response, account *Account) ([]byte, string, error) { - if !account.IsMixed() { - body, msg := s.readOpenAIUpstreamError(resp) - return body, msg, nil - } - limit := openAIUpstreamErrorBodyReadLimitForConfig(s.cfg) - body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) - _ = resp.Body.Close() - if err != nil { - return nil, "", fmt.Errorf("read upstream error body: %w", err) - } - if int64(len(body)) > limit { - return nil, "", fmt.Errorf("upstream error body exceeds refusal read limit") - } - resp.Body = io.NopCloser(strings.NewReader(string(body))) - return body, sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(body))), nil -} - -func writeMixedStreamFailure(c *gin.Context, protocol string, cause error) { - if c.Request.Context().Err() != nil { - return - } - message := "Upstream stream failed before completion" - if !c.Writer.Written() { - if protocol == APIProtocolAnthropic { - writeAnthropicError(c, http.StatusBadGateway, "api_error", message) - } else { - writeChatCompletionsError(c, http.StatusBadGateway, "api_error", message) - } - return - } - payload := map[string]any{"error": map[string]string{"type": "api_error", "message": message}} - prefix := "" - switch protocol { - case APIProtocolAnthropic: - payload["type"] = "error" - prefix = "event: error\n" - case APIProtocolResponses: - payload["type"] = "error" - payload["code"] = "upstream_error" - payload["message"] = message - prefix = "event: error\n" - } - body, _ := json.Marshal(payload) - _, _ = fmt.Fprintf(c.Writer, "%sdata: %s\n\n", prefix, body) - c.Writer.Flush() -} - -func mixedPartialForwardResult(c *gin.Context, resp *http.Response, usage OpenAIUsage, originalModel, billingModel, upstreamModel string, stream bool, startTime time.Time) *OpenAIForwardResult { - return &OpenAIForwardResult{ - RequestID: resp.Header.Get("x-request-id"), UpstreamHeaders: resp.Header, - Usage: usage, Model: originalModel, BillingModel: billingModel, UpstreamModel: upstreamModel, - UpstreamResponseModel: observedUpstreamResponseModel(c), UpstreamResponseModelConflict: observedUpstreamResponseModelConflict(c), - UpstreamResponseServiceTier: observedUpstreamResponseServiceTier(c), Stream: stream, Duration: time.Since(startTime), - } -} - -// The shared legacy body reader discards partial bytes on a read error. Mixed -// must retain bytes already read so observed usage can accompany the error. -func (s *OpenAIGatewayService) readMixedUpstreamBody(resp *http.Response) ([]byte, error) { - limit := resolveUpstreamResponseReadLimit(s.cfg) - body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) - if int64(len(body)) > limit { - return body[:limit], ErrUpstreamResponseBodyTooLarge - } - return body, err -} - -func mixedStreamReadFailure(err error) error { - if err == nil || err == io.EOF { - return ErrOpenAIUpstreamStreamTruncated - } - return fmt.Errorf("upstream stream read failed: %w", err) -} - -func mixedPayloadIsError(payload []byte, eventType string) bool { - return eventType == "error" || eventType == "response.failed" || gjson.GetBytes(payload, "type").String() == "error" || - gjson.GetBytes(payload, "error").IsObject() || gjson.GetBytes(payload, "response.status").String() == "failed" || gjson.GetBytes(payload, "status").String() == "failed" -} - -// A terminal event name alone is not completion evidence: truncated JSON can -// still expose a best-effort type to the legacy SSE parser. -func mixedValidResponsesTerminal(payload []byte, eventType string) bool { - if !gjson.ValidBytes(payload) { - return false - } - response := gjson.GetBytes(payload, "response") - if !response.IsObject() || !response.Get("output").IsArray() { - return false - } - status := response.Get("status").String() - switch eventType { - case "response.completed", "response.done": - return status == "completed" || status == "incomplete" - case "response.incomplete": - return status == "incomplete" - default: - return false - } -} - -func mixedCCBufferedIncomplete(body []byte) bool { - if !gjson.ValidBytes(body) || mixedPayloadIsError(body, "") { - return true - } - choices := gjson.GetBytes(body, "choices").Array() - if len(choices) == 0 { - return true - } - for _, choice := range choices { - if strings.TrimSpace(choice.Get("finish_reason").String()) == "" { - return true - } - } - return false -} diff --git a/backend/internal/service/openai_gateway_mixed_safety_test.go b/backend/internal/service/openai_gateway_mixed_safety_test.go deleted file mode 100644 index de85b7e..0000000 --- a/backend/internal/service/openai_gateway_mixed_safety_test.go +++ /dev/null @@ -1,403 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "errors" - "fmt" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/require" -) - -type mixedFailedReadBody struct{ body io.Reader } - -// Exercise each actual upstream, including fallback attempts. An HTTP 200 -// stream failure must not become account failover or a fabricated completion. -func TestMixedAllUpstreamStreamFailures(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, upstreamProtocol := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - for _, stream := range []bool{false, true} { - for _, failure := range []string{"error", "first_error", "failed", "empty", "truncated", "read_error"} { - t.Run(fmt.Sprintf("%s/to=%s/stream=%t/%s", ingress, upstreamProtocol, stream, failure), func(t *testing.T) { - // Chat and native Messages honor stream=false; the two - // Anthropic conversion paths and Responses bridges force SSE. - sse := stream || upstreamProtocol == APIProtocolResponses || upstreamProtocol == APIProtocolAnthropic && ingress != APIProtocolAnthropic - payload, input, output := mixedFailurePayload(upstreamProtocol, sse, failure) - responses := []*http.Response{} - order := mixedExpectedProtocolOrder(ingress) - for _, protocol := range order { - if protocol == upstreamProtocol { - break - } - responses = append(responses, mixedRefusal(protocol)) - } - response := mixedHTTPResponse(http.StatusOK, payload, sse) - if failure == "read_error" { - response.Body = &mixedFailedReadBody{body: strings.NewReader(payload)} - } - responses = append(responses, response) - attempts := len(responses) - upstream := &httpUpstreamRecorder{responses: responses} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, stream)) - require.Error(t, err) - var replay *UpstreamFailoverError - require.False(t, errors.As(err, &replay), "HTTP 200 must not replay: %v", err) - require.Len(t, upstream.requests, attempts) - require.Contains(t, rec.Body.String(), `"error"`) - require.NotContains(t, rec.Body.String(), "response.completed") - require.NotContains(t, rec.Body.String(), "message_stop") - require.NotContains(t, rec.Body.String(), "[DONE]") - if !stream { - require.Equal(t, http.StatusBadGateway, rec.Code) - require.NotContains(t, rec.Body.String(), `"object":"chat.completion"`) - require.NotContains(t, rec.Body.String(), `"status":"completed"`) - } - require.NotNil(t, result, "partial usage result must survive errors") - require.Equal(t, input, result.Usage.InputTokens) - require.Equal(t, output, result.Usage.OutputTokens) - }) - } - } - } - } -} - -func mixedFailurePayload(protocol string, sse bool, failure string) (string, int, int) { - if failure == "empty" { - return "", 0, 0 - } - if !sse { - usage := `"usage":{"prompt_tokens":9,"completion_tokens":4}` - if protocol == APIProtocolAnthropic { - usage = `"usage":{"input_tokens":9,"output_tokens":4}` - } - if failure == "error" || failure == "first_error" || failure == "failed" { - return `{` + usage + `,"error":{"type":"api_error","message":"Upstream overloaded"}}`, 9, 4 - } - if protocol == APIProtocolAnthropic { - return `{` + usage + `,"id":"msg_1","type":"message","model":"actual","content":[]}`, 9, 4 - } - return `{` + usage + `,"id":"cc_1","choices":[{"index":0,"message":{"role":"assistant","content":"partial"},"finish_reason":null}]}`, 9, 4 - } - prefix := "" - switch protocol { - case APIProtocolChatCompletions: - prefix = "data: " + `{"id":"cc_1","choices":[],"usage":{"prompt_tokens":9,"completion_tokens":4}}` + "\n\n" - case APIProtocolResponses: - prefix = "event: response.created\ndata: " + `{"type":"response.created","response":{"id":"resp_1","model":"actual","status":"in_progress","output":[],"usage":{"input_tokens":9,"output_tokens":4}}}` + "\n\n" - case APIProtocolAnthropic: - prefix = "event: message_start\ndata: " + `{"type":"message_start","message":{"id":"msg_1","type":"message","model":"actual","content":[],"usage":{"input_tokens":9,"output_tokens":0}}}` + "\n\nevent: message_delta\ndata: " + `{"type":"message_delta","usage":{"output_tokens":4}}` + "\n\n" - } - if failure == "first_error" { - usage := `"usage":{"input_tokens":9,"output_tokens":4}` - if protocol == APIProtocolChatCompletions { - usage = `"usage":{"prompt_tokens":9,"completion_tokens":4}` - } - return "event: error\ndata: {\"type\":\"error\"," + usage + `,"error":{"type":"api_error","code":"server_error","message":"Upstream overloaded"}}` + "\n\n", 9, 4 - } - if failure == "error" || failure == "failed" { - if protocol == APIProtocolResponses && failure == "failed" { - prefix += "event: response.failed\ndata: " + `{"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"code":"server_error","message":"Upstream overloaded"},"usage":{"input_tokens":9,"output_tokens":4}}}` + "\n\n" - } else { - prefix += "event: error\ndata: " + `{"type":"error","error":{"type":"api_error","code":"server_error","message":"Upstream overloaded"}}` + "\n\n" - } - } - return prefix, 9, 4 -} - -func TestMixedEmptyChatCompletionNeverAccountFailsOver(t *testing.T) { - for _, upstreamProtocol := range []string{APIProtocolChatCompletions, APIProtocolResponses} { - t.Run(upstreamProtocol, func(t *testing.T) { - responses := []*http.Response{} - payload := "data: " + `{"id":"cc_empty","choices":[{"index":0,"delta":{"content":""},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n" - if upstreamProtocol == APIProtocolResponses { - responses = append(responses, mixedRefusal(APIProtocolChatCompletions)) - payload = "event: response.completed\ndata: " + `{"type":"response.completed","response":{"id":"resp_empty","status":"completed","output":[]}}` + "\n\n" - } - responses = append(responses, mixedHTTPResponse(http.StatusOK, payload, true)) - attempts := len(responses) - upstream := &httpUpstreamRecorder{responses: responses} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), APIProtocolChatCompletions, largeRawChatCompletionsBody()) - require.Error(t, err) - var replay *UpstreamFailoverError - require.False(t, errors.As(err, &replay)) - require.Len(t, upstream.requests, attempts) - require.NotNil(t, result) - require.Contains(t, rec.Body.String(), `"error"`) - require.NotContains(t, rec.Body.String(), "[DONE]") - }) - } -} - -func TestMixedHTTPFailuresStillAllowAccountFailover(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, target := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - for _, status := range []int{http.StatusUnauthorized, http.StatusTooManyRequests} { - t.Run(fmt.Sprintf("%s/to=%s/status=%d", ingress, target, status), func(t *testing.T) { - order := mixedExpectedProtocolOrder(ingress) - responses := []*http.Response{} - for _, protocol := range order { - if protocol == target { - break - } - responses = append(responses, mixedRefusal(protocol)) - } - responses = append(responses, mixedHTTPResponse(status, `{"error":{"type":"api_error","message":"Upstream request denied"}}`, false)) - attempts := len(responses) - upstream := &httpUpstreamRecorder{responses: responses} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - _, _, _, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, true)) - var failover *UpstreamFailoverError - require.ErrorAs(t, err, &failover) - require.Equal(t, status, failover.StatusCode) - require.Len(t, upstream.requests, attempts) - }) - } - } - } -} - -func TestMixedResponsesDoneWithoutTerminalNeverSucceeds(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, stream := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/stream=%t", ingress, stream), func(t *testing.T) { - payload, _, _ := mixedFailurePayload(APIProtocolResponses, true, "truncated") - responses := mixedResponsesForTarget(ingress, APIProtocolResponses, mixedHTTPResponse(200, payload+"data: [DONE]\n\n", true)) - attempts := len(responses) - upstream := &httpUpstreamRecorder{responses: responses} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, stream)) - require.Error(t, err) - var replay *UpstreamFailoverError - require.False(t, errors.As(err, &replay)) - require.Len(t, upstream.requests, attempts) - require.NotNil(t, result) - require.Equal(t, 9, result.Usage.InputTokens) - require.Contains(t, rec.Body.String(), `"error"`) - require.NotContains(t, rec.Body.String(), "response.completed") - require.NotContains(t, rec.Body.String(), "message_stop") - require.NotContains(t, rec.Body.String(), "[DONE]") - }) - } - } -} - -func (b *mixedFailedReadBody) Read(p []byte) (int, error) { - n, err := b.body.Read(p) - if err == io.EOF { - return n, io.ErrUnexpectedEOF - } - return n, err -} - -func TestMixedPartialStreamNeverFinalizesAndCompleteWithoutDoneWorks(t *testing.T) { - for _, protocol := range []string{APIProtocolResponses, APIProtocolAnthropic, APIProtocolChatCompletions} { - for _, complete := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/complete=%t", protocol, complete), func(t *testing.T) { - payload := "data: " + `{"id":"cc_1","model":"actual","choices":[{"index":0,"delta":{"content":"hello"},"finish_reason":null}]}` + "\n\n" - if complete { - // A finish_reason is terminal evidence even without a DONE sentinel. - payload = strings.ReplaceAll(mixedChatToolSSE(), "data: [DONE]\n\n", "") - } else { - // Usage alone is not completion evidence for Mixed. - payload += "data: " + `{"choices":[],"usage":{"prompt_tokens":9,"completion_tokens":4}}` + "\n\n" - } - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(protocol, APIProtocolChatCompletions, mixedHTTPResponse(200, payload, true))} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), protocol, mixedClientBody(protocol, true)) - require.Len(t, upstream.requests, mixedExpectedAttempts(protocol, APIProtocolChatCompletions)) - if complete { - require.NoError(t, err) - require.NotNil(t, result) - return - } - require.ErrorIs(t, err, ErrOpenAIUpstreamStreamTruncated) - require.Contains(t, rec.Body.String(), `"error"`) - require.NotContains(t, rec.Body.String(), "response.completed") - require.NotContains(t, rec.Body.String(), "message_stop") - require.NotContains(t, rec.Body.String(), "[DONE]") - require.NotNil(t, result) - require.Equal(t, 9, result.Usage.InputTokens) - }) - } - } -} - -func TestMixedCancellationClosesAllProtocolAttempts(t *testing.T) { - for _, protocol := range []string{APIProtocolResponses, APIProtocolAnthropic, APIProtocolChatCompletions} { - for _, target := range []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - for _, mode := range []string{"header", "error_body", "json_body", "sse_body"} { - t.Run(fmt.Sprintf("%s/to=%s/%s", protocol, target, mode), func(t *testing.T) { - arrived, serverDone := make(chan struct{}), make(chan struct{}) - var calls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - calls.Add(1) - _, _ = io.Copy(io.Discard, r.Body) - if r.URL.Path != protocolUpstreamEndpoint(target) { - refused := APIProtocolChatCompletions - if r.URL.Path == "/v1/responses" { - refused = APIProtocolResponses - } - if r.URL.Path == "/v1/messages" { - refused = APIProtocolAnthropic - } - resp := mixedRefusal(refused) - w.WriteHeader(resp.StatusCode) - _, _ = io.Copy(w, resp.Body) - return - } - if mode != "header" { - w.Header().Set("Content-Type", "application/json") - status := http.StatusOK - if mode == "sse_body" { - w.Header().Set("Content-Type", "text/event-stream") - } - if mode == "error_body" { - status = http.StatusNotFound - } - w.WriteHeader(status) - w.(http.Flusher).Flush() - } - close(arrived) - <-r.Context().Done() - close(serverDone) - })) - t.Cleanup(func() { server.CloseClientConnections(); server.Close() }) - targetURL, err := url.Parse(server.URL) - require.NoError(t, err) - upstream := &headerCancelHTTPUpstream{client: server.Client(), target: targetURL} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - body := mixedClientBody(protocol, mode == "sse_body") - c, _ := gin.CreateTestContext(httptest.NewRecorder()) - c.Request = httptest.NewRequest(http.MethodPost, protocolUpstreamEndpoint(protocol), strings.NewReader(string(body))).WithContext(ctx) - c.Set("api_key", &APIKey{Group: &Group{Platform: PlatformMixed}}) - done := make(chan error, 1) - go func() { - var forwardErr error - detached := context.WithoutCancel(ctx) - switch protocol { - case APIProtocolResponses: - _, forwardErr = svc.Forward(detached, c, mixedTestAccount(), body) - case APIProtocolChatCompletions: - _, forwardErr = svc.ForwardAsChatCompletions(detached, c, mixedTestAccount(), body, "", "") - case APIProtocolAnthropic: - _, forwardErr = svc.ForwardAsAnthropic(detached, c, mixedTestAccount(), body, "", "") - } - done <- forwardErr - }() - select { - case <-arrived: - case err := <-done: - t.Fatalf("did not reach blocking attempt: %v", err) - case <-time.After(2 * time.Second): - t.Fatal("upstream did not arrive") - } - before := calls.Load() - cancel() - select { - case err := <-done: - require.ErrorIs(t, err, context.Canceled) - case <-time.After(2 * time.Second): - t.Fatal("canceled forwarding did not terminate") - } - select { - case <-serverDone: - case <-time.After(time.Second): - t.Fatal("upstream body/request was not released") - } - require.Equal(t, before, calls.Load(), "cancellation must not replay") - }) - } - } - } -} -func (*mixedFailedReadBody) Close() error { return nil } - -func TestMixedIncompleteRefusalNeverReplays(t *testing.T) { - for _, protocol := range []string{APIProtocolResponses, APIProtocolAnthropic, APIProtocolChatCompletions} { - t.Run(protocol, func(t *testing.T) { - resp := mixedRefusal(protocol) - data, err := io.ReadAll(resp.Body) - require.NoError(t, err) - resp.Body = &mixedFailedReadBody{body: strings.NewReader(string(data))} - upstream := &httpUpstreamRecorder{responses: []*http.Response{resp, mixedHTTPResponse(200, mixedChatToolJSON, false)}} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - _, _, _, err = mixedForward(t, svc, mixedTestAccount(), protocol, mixedClientBody(protocol, false)) - require.ErrorIs(t, err, io.ErrUnexpectedEOF) - require.Len(t, upstream.requests, 1) - }) - } -} - -func TestMixedFullEndpointBasesSwitchPaths(t *testing.T) { - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig()} - for _, prefix := range []string{"", "/vendor", "/vendor/v4"} { - for _, source := range []string{"/v1/chat/completions", "/v1/responses", "/v1/messages"} { - t.Run(prefix+source, func(t *testing.T) { - account := mixedTestAccount() - // A versioned vendor base has its own version, without an extra v1. - configured := prefix + source - expectedPrefix := prefix + "/v1" - if strings.HasSuffix(prefix, "/v4") { - configured = prefix + strings.TrimPrefix(source, "/v1") - expectedPrefix = prefix - } - account.Credentials["base_url"] = "http://upstream.example" + configured - chat, err := svc.openAIChatCompletionsTargetURL(account) - require.NoError(t, err) - require.Equal(t, "http://upstream.example"+expectedPrefix+"/chat/completions", chat) - require.Equal(t, "http://upstream.example"+expectedPrefix+"/responses", buildOpenAIResponsesURL(account.GetOpenAIBaseURL())) - messages, err := svc.nativeAnthropicTargetURL(account) - require.NoError(t, err) - require.Equal(t, "http://upstream.example"+expectedPrefix+"/messages", messages) - }) - } - } -} - -func TestMixedUnsupportedRequestsReturnClientError(t *testing.T) { - for _, protocol := range []string{APIProtocolResponses, APIProtocolAnthropic, APIProtocolChatCompletions} { - for _, field := range []string{`"audio":{}`, `"modalities":["audio"]`, `"context_management":[]`, `"web_search_options":{}`, `"tools":[{"type":"web_search_20250305"}]`, `"tools":[{"type":"web_search"}]`, `"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://image.example/a"}}]}]`, `"input":[{"role":"user","content":[{"type":"input_audio","data":"AA"}]}]`} { - t.Run(fmt.Sprintf("%s/%s", protocol, field), func(t *testing.T) { - upstream := &httpUpstreamRecorder{err: errors.New("must not send")} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - _, rec, _, err := mixedForward(t, svc, mixedTestAccount(), protocol, []byte(`{"model":"alias",`+field+`}`)) - require.Error(t, err) - require.Equal(t, http.StatusBadRequest, rec.Code) - require.Contains(t, rec.Body.String(), `"invalid_request_error"`) - require.Empty(t, upstream.requests) - }) - } - } -} - -func TestMixedForwardDisablesRedirectsAllProtocols(t *testing.T) { - for _, protocol := range []string{APIProtocolResponses, APIProtocolAnthropic, APIProtocolChatCompletions} { - order := mixedExpectedProtocolOrder(protocol) - upstream := &httpUpstreamRecorder{responses: []*http.Response{mixedRefusal(order[0]), mixedRefusal(order[1]), mixedRefusal(order[2])}} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - _, _, _, _ = mixedForward(t, svc, mixedTestAccount(), protocol, mixedClientBody(protocol, false)) - require.Len(t, upstream.requests, 3) - for _, req := range upstream.requests { - require.True(t, HTTPUpstreamRedirectsDisabled(req.Context()), req.URL.String()) - } - } - account := mixedTestAccount() - req, err := buildOpenAIAPIKeyModelsRequest(context.Background(), account, func(s string) (string, error) { return s, nil }) - require.NoError(t, err) - require.True(t, HTTPUpstreamRedirectsDisabled(req.Context())) -} diff --git a/backend/internal/service/openai_gateway_mixed_stream_error.go b/backend/internal/service/openai_gateway_mixed_stream_error.go deleted file mode 100644 index 4f90a5f..0000000 --- a/backend/internal/service/openai_gateway_mixed_stream_error.go +++ /dev/null @@ -1,246 +0,0 @@ -package service - -import ( - "context" - "crypto/sha256" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - "unicode" - - "github.com/Wei-Shaw/sub2api/internal/util/logredact" - "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" -) - -const mixedCCAccountContextKey = "mixed_cc_error_account" -const mixedCCTargetContextKey = "mixed_cc_error_target" - -// This error owns only a bounded diagnostic, never a provider payload. Provider -// messages can contain an entire echoed request, even inside error.message. -type mixedCCFailure struct { - summary string - nonBillable bool - cause error -} - -func (e *mixedCCFailure) Error() string { return e.summary } -func (e *mixedCCFailure) Unwrap() error { return e.cause } - -func mixedCCErrorNonBillable(err error) bool { - var failure *mixedCCFailure - return errors.As(err, &failure) && failure.nonBillable -} - -func mixedCCAccount(c *gin.Context) *Account { - if c == nil { - return nil - } - value, _ := c.Get(mixedCCAccountContextKey) - account, _ := value.(*Account) - return account -} - -func mixedCCSafeText(c *gin.Context, value string, limit int) string { - if account := mixedCCAccount(c); account != nil { - if secret := strings.TrimSpace(account.GetCredential("api_key")); secret != "" { - value = strings.ReplaceAll(value, secret, "[redacted]") - } - } - value = logredact.RedactText(value, "api_key", "authorization", "token", "secret", "prompt", "input", "messages", "content") - return truncateString(strings.Map(func(r rune) rune { - if unicode.IsControl(r) { - return ' ' - } - return r - }, value), limit) -} - -// Only vocabulary with a defined diagnostic meaning may survive. Unknown -// identifiers get a fingerprint so an echoed prompt cannot become a log field. -func mixedCCSafeIdentifier(value string, allowed string) string { - value = strings.TrimSpace(value) - if value == "" { - return "none" - } - for _, candidate := range strings.Fields(allowed) { - if value == candidate { - return value - } - } - sum := sha256.Sum256([]byte(value)) - return fmt.Sprintf("redacted:%x", sum[:6]) -} - -func newMixedCCFailure(c *gin.Context, payload []byte, event, kind string, outputStarted bool, cause error) *mixedCCFailure { - valid := gjson.ValidBytes(payload) - message, code, param := "", "", "" - if valid { - message = firstValidTrimmedGJSONString(payload, "response.error.message", "error.message", "message") - code = firstValidTrimmedGJSONString(payload, "response.error.code", "error.code", "code", "error.type") - param = firstValidTrimmedGJSONString(payload, "response.error.param", "error.param", "param") - if event == "" { - event = firstValidTrimmedGJSONString(payload, "type") - } - } - policy := mixedCCPayloadHasPolicy(payload) - nonBillable := kind == "upstream_error" && valid && !outputStarted && !policy && isOpenAINonBillableRequestError(message, payload) - // Save a classification of the message, not arbitrary provider prose. - messageClass := "redacted upstream message" - lower := strings.ToLower(message + " " + code) - switch { - case policy: - messageClass = "policy refusal" - case isOpenAIContextWindowError(message, payload) && valid: - messageClass = "context window exceeded" - case nonBillable: - messageClass = "invalid thinking budget" - case strings.Contains(lower, "unsupported") || strings.Contains(lower, "unknown parameter"): - messageClass = "unsupported request parameter" - case strings.Contains(lower, "quota") || strings.Contains(lower, "balance") || strings.Contains(lower, "billing"): - messageClass = "upstream account unavailable" - case strings.Contains(lower, "rate_limit") || strings.Contains(lower, "rate limit"): - messageClass = "upstream rate limited" - case strings.Contains(lower, "overload") || strings.Contains(lower, "server_error"): - messageClass = "upstream server error" - case kind != "upstream_error": - messageClass = kind - } - // Redact untrusted fields before mapping them to diagnostic vocabulary. - // Redacting the assembled summary would also erase the harmless error code - // because logredact treats the generic key "code" as an OAuth secret. - code = mixedCCSafeIdentifier(mixedCCSafeText(c, code, 256), "invalid_request_error invalid_parameter unsupported_parameter unknown_parameter context_length_exceeded context_window_exceeded insufficient_quota billing_not_active rate_limit_exceeded server_error api_error model_not_found invalid_api_key content_policy_violation content_filter cyber_policy") - param = mixedCCSafeIdentifier(mixedCCSafeText(c, param, 256), "model messages input tools tool_choice response_format reasoning reasoning_effort thinking thinking.budget_tokens budget_tokens max_tokens max_completion_tokens stream stream_options temperature top_p parallel_tool_calls") - event = mixedCCSafeIdentifier(mixedCCSafeText(c, event, 256), "error response.failed response.incomplete") - summary := fmt.Sprintf("Mixed Chat Completions %s: event=%s code=%s param=%s message=%s endpoint=/v1/chat/completions", kind, event, code, param, messageClass) - return &mixedCCFailure{summary: summary, nonBillable: nonBillable, cause: cause} -} - -func mixedCCPayloadHasPolicy(payload []byte) bool { - if !gjson.ValidBytes(payload) { - return false - } - if hit, _, _ := detectOpenAICyberPolicy(payload); hit { - return true - } - for _, choice := range gjson.GetBytes(payload, "choices").Array() { - if choice.Get("delta.refusal").String() != "" || choice.Get("message.refusal").String() != "" || choice.Get("finish_reason").String() == "content_filter" { - return true - } - } - classification := strings.ToLower(firstValidTrimmedGJSONString(payload, "response.error.code", "error.code", "code") + " " + firstValidTrimmedGJSONString(payload, "response.error.type", "error.type")) - for _, marker := range []string{"policy", "safety", "content_filter", "refusal"} { - if strings.Contains(classification, marker) { - return true - } - } - return false -} - -func mixedCCPayloadHasOutput(payload []byte) bool { - for _, choice := range gjson.GetBytes(payload, "choices").Array() { - for _, field := range []string{"delta", "message"} { - part := choice.Get(field) - if part.Get("content").String() != "" || part.Get("refusal").String() != "" || part.Get("reasoning_content").String() != "" || part.Get("reasoning").String() != "" || part.Get("reasoning_summary").String() != "" { - return true - } - if function := part.Get("function_call"); function.Get("name").String() != "" || function.Get("arguments").String() != "" { - return true - } - for _, tool := range part.Get("tool_calls").Array() { - if tool.Get("id").String() != "" || tool.Get("function.name").String() != "" || tool.Get("function.arguments").String() != "" { - return true - } - } - } - } - return false -} - -func mixedCCPayloadIsError(payload []byte, event string) bool { - return mixedPayloadIsError(payload, event) || gjson.GetBytes(payload, "type").String() == "response.failed" || gjson.GetBytes(payload, "response.error").IsObject() -} - -// Raw Chat preserves these native fields, but the existing Responses/Messages -// converters do not implement them. Fail explicitly instead of dropping a tool -// call or refusal and synthesizing a successful empty completion. -func mixedCCUnsupportedConversionOutput(payload []byte) bool { - for _, choice := range gjson.GetBytes(payload, "choices").Array() { - for _, field := range []string{"delta", "message"} { - part := choice.Get(field) - if part.Get("refusal").String() != "" || part.Get("reasoning_summary").String() != "" { - return true - } - if function := part.Get("function_call"); function.Get("name").String() != "" || function.Get("arguments").String() != "" { - return true - } - } - } - return false -} - -func observeMixedCCPayload(c *gin.Context, payload []byte, eventType ...string) { - if !gjson.ValidBytes(payload) { - return - } - observer := upstreamResponseModelObserverFromContext(c) - if observer == nil { - observer = beginUpstreamResponseModelObservation(c) - } - event := strings.TrimSpace(gjson.GetBytes(payload, "type").String()) - if len(eventType) > 0 && eventType[0] != "" { - event = eventType[0] - } - observer.ObserveOpenAI(payload, event) -} - -// Both raw and converted buffered paths must keep metering and attribution -// received before a read failure or a HTTP-200 provider error. -func (s *OpenAIGatewayService) readMixedCCResponse(c *gin.Context, resp *http.Response) ([]byte, OpenAIUsage, error) { - body, err := s.readMixedUpstreamBody(resp) - usage, _ := extractOpenAIUsageFromJSONBytes(body) - observeMixedCCPayload(c, body) - if err == nil && (mixedCCBufferedIncomplete(body) || mixedCCPayloadIsError(body, "")) { - kind := "incomplete_response" - if !gjson.ValidBytes(body) { - kind = "invalid_json" - } else if mixedCCPayloadIsError(body, "") { - kind = "upstream_error" - } - err = newMixedCCFailure(c, body, "", kind, mixedCCPayloadHasOutput(body) || mixedCCPayloadHasPolicy(body), nil) - } - if err != nil { - err = s.recordMixedCCFailure(c, resp, err) - } - return body, usage, err -} - -func (s *OpenAIGatewayService) recordMixedCCFailure(c *gin.Context, resp *http.Response, err error) error { - var failure *mixedCCFailure - if !errors.As(err, &failure) { - kind := "read_error" - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - kind = "cancelled" - } else if errors.Is(err, ErrOpenAIUpstreamStreamTruncated) { - kind = "stream_truncated" - } - failure = newMixedCCFailure(c, nil, "", kind, false, err) - } - // Pass only the safe summary to the shared Ops recorder. In particular, - // enabling raw error logging must never persist the original SSE payload. - s.recordOpenAIStreamUpstreamError(c, mixedCCAccount(c), false, mixedCCSafeText(c, resp.Header.Get("x-request-id"), 128), "stream_failed", nil, failure.summary) - if c != nil { - if value, ok := c.Get(OpsUpstreamErrorsKey); ok { - if events, ok := value.([]*OpsUpstreamErrorEvent); ok && len(events) > 0 { - event := events[len(events)-1] - if target, err := url.Parse(c.GetString(mixedCCTargetContextKey)); err == nil { - // Endpoint attribution never includes URL credentials or query. - event.UpstreamURL = mixedCCSafeText(c, target.Scheme+"://"+target.Host+target.Path, 256) - } - } - } - } - return failure -} diff --git a/backend/internal/service/openai_gateway_mixed_stream_error_test.go b/backend/internal/service/openai_gateway_mixed_stream_error_test.go deleted file mode 100644 index 593c867..0000000 --- a/backend/internal/service/openai_gateway_mixed_stream_error_test.go +++ /dev/null @@ -1,300 +0,0 @@ -//go:build unit - -package service - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -const mixedCCFailureUsage = `"usage":{"prompt_tokens":19,"completion_tokens":3}` - -func TestMixedCCForwardSafeStreamEvidence(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, rawLog := range []bool{false, true} { - for _, failure := range []struct{ name, sse, diagnostic string }{ - {"named-error", "event: error\ndata: " + `{"model":"actual","error":{"type":"invalid_request_error","code":"unsupported_parameter","param":"reasoning_effort","message":"Unsupported parameter: reasoning_effort test-secret echo-private-prompt","request":{"messages":[{"content":"echo-private-prompt"}]}},` + mixedCCFailureUsage + "}\n\n", "code=unsupported_parameter param=reasoning_effort"}, - {"response-failed", "event: response.failed\ndata: " + `{"type":"response.failed","response":{"model":"actual","status":"failed","error":{"code":"context_length_exceeded","message":"Maximum context length exceeded test-secret echo-private-prompt"}},` + mixedCCFailureUsage + "}\n\n", "context window exceeded"}, - {"invalid-json", "data: {not-json test-secret echo-private-prompt}\n\n", "invalid_json"}, - } { - t.Run(fmt.Sprintf("%s/raw=%t/%s", ingress, rawLog, failure.name), func(t *testing.T) { - response := mixedHTTPResponse(200, failure.sse, true) - response.Header.Set("x-request-id", "rid-cc-evidence") - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(ingress, APIProtocolChatCompletions, response)} - cfg := rawChatCompletionsTestConfig() - cfg.Gateway.LogUpstreamErrorBody = rawLog - svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} - result, rec, c, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, true)) - require.Error(t, err) - require.NotNil(t, result) - require.Len(t, upstream.requests, mixedExpectedAttempts(ingress, APIProtocolChatCompletions)) - var replay *UpstreamFailoverError - require.False(t, errors.As(err, &replay)) - for _, terminal := range []string{"response.completed", "message_stop", "[DONE]"} { - require.NotContains(t, rec.Body.String(), terminal) - } - value, exists := c.Get(OpsUpstreamErrorsKey) - require.True(t, exists, "stream failure must have an Ops event") - events := value.([]*OpsUpstreamErrorEvent) - require.Len(t, events, 1) - require.Equal(t, PlatformMixed, events[0].Platform) - require.Equal(t, int64(702), events[0].AccountID) - require.Equal(t, "rid-cc-evidence", events[0].UpstreamRequestID) - require.Contains(t, events[0].Message, failure.diagnostic) - require.Contains(t, events[0].Message, "endpoint=/v1/chat/completions") - require.Equal(t, events[0].Message, c.GetString(OpsUpstreamErrorMessageKey)) - encoded, marshalErr := json.Marshal(events) - require.NoError(t, marshalErr) - for _, unsafe := range []string{"test-secret", "echo-private-prompt", "request\"", "messages\""} { - require.NotContains(t, string(encoded), unsafe) - require.NotContains(t, rec.Body.String(), unsafe) - require.NotContains(t, err.Error(), unsafe) - } - require.Empty(t, events[0].Detail) - if failure.name != "invalid-json" { - require.Equal(t, "actual", result.UpstreamResponseModel) - require.Equal(t, 19, result.Usage.InputTokens) - require.Equal(t, 3, result.Usage.OutputTokens) - } - }) - } - } - } -} - -func TestMixedCCForwardEmptyAndToolOnly(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, test := range []struct { - name, payload string - wantError bool - }{ - {"bare-done", "data: [DONE]\n\n", true}, - {"usage-only", "data: {\"model\":\"actual\",\"choices\":[]," + mixedCCFailureUsage + "}\n\ndata: [DONE]\n\n", true}, - {"empty-content", "data: {\"choices\":[{\"delta\":{\"content\":\"\"},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n", true}, - {"tool-only", mixedChatToolSSE(), false}, - {"tool-without-done", strings.ReplaceAll(mixedChatToolSSE(), "data: [DONE]\n\n", ""), false}, - } { - t.Run(ingress+"/"+test.name, func(t *testing.T) { - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(ingress, APIProtocolChatCompletions, mixedHTTPResponse(200, test.payload, true))} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, true)) - require.Len(t, upstream.requests, mixedExpectedAttempts(ingress, APIProtocolChatCompletions)) - require.NotNil(t, result) - if test.wantError { - require.Error(t, err) - for _, terminal := range []string{"response.completed", "message_stop", "[DONE]"} { - require.NotContains(t, rec.Body.String(), terminal) - } - require.Nil(t, result.FirstTokenMs) - } else { - require.NoError(t, err) - require.Contains(t, rec.Body.String(), "call_1") - require.Equal(t, "actual", result.UpstreamResponseModel) - require.Equal(t, 9, result.Usage.InputTokens) - require.Equal(t, 4, result.Usage.OutputTokens) - } - }) - } - } -} - -func TestMixedCCForwardDeterministicFailureBilling(t *testing.T) { - failure := "event: response.failed\ndata: " + `{"type":"response.failed","response":{"model":"actual","status":"failed","error":{"code":"context_length_exceeded","message":"Maximum context length exceeded"}},` + mixedCCFailureUsage + "}\n\n" - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, test := range []struct { - name, prefix, errorSSE string - nonBillable bool - }{ - {"before-output", "", failure, true}, - {"after-text", "data: " + `{"model":"first-model","choices":[{"delta":{"content":"hello"}}]}` + "\n\n", failure, false}, - {"after-reasoning", "data: " + `{"model":"first-model","choices":[{"delta":{"reasoning_content":"reason"}}]}` + "\n\n", failure, false}, - {"after-tool", strings.Split(mixedChatToolSSE(), "data: ")[1], failure, false}, - {"after-refusal", "data: " + `{"model":"first-model","choices":[{"delta":{"refusal":"policy"}}]}` + "\n\n", failure, false}, - {"cyber", "", strings.ReplaceAll(failure, `"context_length_exceeded"`, `"cyber_policy"`), false}, - } { - if test.name == "after-refusal" && ingress != APIProtocolChatCompletions { - // Unsupported converted output must stop before the later error. - continue - } - t.Run(ingress+"/"+test.name, func(t *testing.T) { - prefix := test.prefix - if test.name == "after-tool" { - prefix = "data: " + prefix - } - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(ingress, APIProtocolChatCompletions, mixedHTTPResponse(200, prefix+test.errorSSE, true))} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, true)) - require.Error(t, err) - require.NotNil(t, result) - require.Len(t, upstream.requests, mixedExpectedAttempts(ingress, APIProtocolChatCompletions)) - require.Equal(t, test.nonBillable, result.NonBillableUpstreamError) - require.Equal(t, 19, result.Usage.InputTokens) - require.Equal(t, 3, result.Usage.OutputTokens) - require.Equal(t, "actual", result.UpstreamResponseModel) - require.Equal(t, strings.HasPrefix(test.name, "after-") && test.name != "after-tool", result.UpstreamResponseModelConflict) - require.NotContains(t, rec.Body.String(), "response.completed") - require.NotContains(t, rec.Body.String(), "message_stop") - require.NotContains(t, rec.Body.String(), "[DONE]") - }) - } - } -} - -func TestMixedCCForwardBufferedAttributionAndFailure(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, success := range []bool{true, false} { - t.Run(fmt.Sprintf("%s/success=%t", ingress, success), func(t *testing.T) { - payload := mixedChatToolJSON - if !success { - payload = `{"model":"actual","error":{"code":"context_length_exceeded","message":"Maximum context length exceeded test-secret echo-private-prompt"},` + mixedCCFailureUsage + `}` - } - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(ingress, APIProtocolChatCompletions, mixedHTTPResponse(200, payload, false))} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, c, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, false)) - require.NotNil(t, result) - require.Len(t, upstream.requests, mixedExpectedAttempts(ingress, APIProtocolChatCompletions)) - require.Equal(t, "actual", result.UpstreamResponseModel) - require.False(t, result.UpstreamResponseModelConflict) - if success { - require.NoError(t, err) - } else { - require.Error(t, err) - require.True(t, result.NonBillableUpstreamError) - require.Equal(t, 19, result.Usage.InputTokens) - require.Equal(t, 3, result.Usage.OutputTokens) - require.NotEmpty(t, c.GetString(OpsUpstreamErrorMessageKey)) - require.NotContains(t, rec.Body.String(), "test-secret") - } - }) - } - } -} - -func TestMixedCCForwardReadFailureRetainsUsage(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} { - for _, cause := range []error{io.ErrUnexpectedEOF, context.Canceled} { - t.Run(ingress+"/"+cause.Error(), func(t *testing.T) { - payload := "data: {\"choices\":[]," + mixedCCFailureUsage + "}\n\n" - response := mixedHTTPResponse(200, "", true) - response.Body = &errTailReader{data: []byte(payload), err: cause} - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(ingress, APIProtocolChatCompletions, response)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, true)) - require.Error(t, err) - require.True(t, errors.Is(err, cause)) - require.Len(t, upstream.requests, mixedExpectedAttempts(ingress, APIProtocolChatCompletions)) - require.NotNil(t, result) - require.Equal(t, 19, result.Usage.InputTokens) - require.Equal(t, 3, result.Usage.OutputTokens) - require.False(t, result.NonBillableUpstreamError) - require.NotContains(t, rec.Body.String(), "[DONE]") - }) - } - } -} - -// Native raw Chat supports both the legacy function_call shape and refusals. -// Mixed aggregation must not turn these existing outputs into empty failures. -func TestMixedCCRawPreservesNativeOutputShapes(t *testing.T) { - for _, test := range []struct{ name, delta, finish string }{ - {"legacy-function", `{"function_call":{"name":"lookup","arguments":"{}"}}`, "function_call"}, - {"refusal", `{"refusal":"Cannot assist with that request"}`, "stop"}, - {"reasoning-summary", `{"reasoning_summary":"Checking the request"}`, "stop"}, - } { - for _, failAfterOutput := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/failure=%t", test.name, failAfterOutput), func(t *testing.T) { - payload := fmt.Sprintf("data: {\"model\":\"actual\",\"choices\":[{\"delta\":%s}]}\n\n", test.delta) - if failAfterOutput { - payload += "event: error\ndata: " + `{"error":{"code":"context_length_exceeded","message":"Maximum context length exceeded"},` + mixedCCFailureUsage + "}\n\n" - } else { - payload += fmt.Sprintf("data: {\"model\":\"actual\",\"choices\":[{\"delta\":{},\"finish_reason\":%q}],%s}\n\ndata: [DONE]\n\n", test.finish, mixedCCFailureUsage) - } - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(200, payload, true)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), APIProtocolChatCompletions, mixedClientBody(APIProtocolChatCompletions, true)) - require.NotNil(t, result) - require.Len(t, upstream.requests, 1) - require.Contains(t, rec.Body.String(), test.delta) - require.NotNil(t, result.FirstTokenMs) - require.False(t, result.NonBillableUpstreamError, "already generated output must not be marked free") - if failAfterOutput { - require.Error(t, err) - require.NotContains(t, rec.Body.String(), "[DONE]") - } else { - require.NoError(t, err) - require.Contains(t, rec.Body.String(), "[DONE]") - } - }) - } - } -} - -func TestMixedCCConvertedUnsupportedOutputNeverCompletes(t *testing.T) { - for _, ingress := range []string{APIProtocolResponses, APIProtocolAnthropic} { - for _, output := range []string{ - `{"function_call":{"name":"lookup","arguments":"{}"}}`, - `{"refusal":"Cannot assist with that request"}`, - `{"reasoning_summary":"Checking the request"}`, - } { - for _, stream := range []bool{false, true} { - t.Run(fmt.Sprintf("%s/%s/stream=%t", ingress, output, stream), func(t *testing.T) { - payload := fmt.Sprintf(`{"model":"actual","choices":[{"message":%s,"finish_reason":"stop"}],%s}`, output, mixedCCFailureUsage) - if stream { - payload = fmt.Sprintf("data: {\"model\":\"actual\",\"choices\":[{\"delta\":%s}],%s}\n\ndata: [DONE]\n\n", output, mixedCCFailureUsage) - } - upstream := &httpUpstreamRecorder{responses: mixedResponsesForTarget(ingress, APIProtocolChatCompletions, mixedHTTPResponse(200, payload, stream))} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), ingress, mixedClientBody(ingress, stream)) - require.Error(t, err) - require.Contains(t, err.Error(), "unsupported_conversion_output") - require.NotNil(t, result) - require.False(t, result.NonBillableUpstreamError) - require.Equal(t, 19, result.Usage.InputTokens) - require.Equal(t, 3, result.Usage.OutputTokens) - require.Len(t, upstream.requests, mixedExpectedAttempts(ingress, APIProtocolChatCompletions)) - for _, terminal := range []string{"response.completed", "message_stop", "[DONE]"} { - require.NotContains(t, rec.Body.String(), terminal) - } - }) - } - } - } -} - -func TestMixedNativeResponsesFailureKeepsRequestErrorClassification(t *testing.T) { - for _, test := range []struct { - name, prefix, code string - nonBillable bool - }{ - {"before-output", "", "context_length_exceeded", true}, - {"after-text", `{"type":"response.output_text.delta","delta":"hello"}`, "context_length_exceeded", false}, - {"after-tool", `{"type":"response.function_call_arguments.delta","delta":"{}"}`, "context_length_exceeded", false}, - {"after-refusal", `{"type":"response.refusal.delta","delta":"Cannot assist"}`, "context_length_exceeded", false}, - {"policy", "", "cyber_policy", false}, - } { - t.Run(test.name, func(t *testing.T) { - payload := "" - if test.prefix != "" { - payload += "data: " + test.prefix + "\n\n" - } - payload += fmt.Sprintf("event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\",\"model\":\"actual\",\"error\":{\"code\":%q,\"message\":\"Maximum context length exceeded\"},\"usage\":{\"input_tokens\":19,\"output_tokens\":3}}}\n\n", test.code) - upstream := &httpUpstreamRecorder{resp: mixedHTTPResponse(200, payload, true)} - svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - result, rec, _, err := mixedForward(t, svc, mixedTestAccount(), APIProtocolResponses, mixedClientBody(APIProtocolResponses, true)) - require.Error(t, err) - require.NotNil(t, result) - require.Equal(t, test.nonBillable, result.NonBillableUpstreamError) - require.Equal(t, 19, result.Usage.InputTokens) - require.Equal(t, 3, result.Usage.OutputTokens) - require.Len(t, upstream.requests, 1) - require.NotContains(t, rec.Body.String(), "response.completed") - }) - } -} diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index aa6ad6c..07a5b6f 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -672,14 +672,15 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( if account.usesNativeResponsesUpstream() && account.IsAdaptiveAPIProtocol() { baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses) } + if strings.TrimSpace(baseURL) == "" && (account.IsMultiProtocolAPIKey() || account.IsGeminiOpenAIProtocol()) { + return nil, fmt.Errorf("account %d has no configured Responses endpoint", account.ID) + } if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return nil, err } - targetURL = buildOpenAIResponsesURLForPlatform(account.openAIWirePlatform(), validatedURL) - } else if account.IsMixed() || account.IsMultiProtocolAPIKey() || account.IsGeminiOpenAIProtocol() { - return nil, fmt.Errorf("account requires an explicit Responses endpoint") + targetURL = buildOpenAIResponsesURLForPlatform(account.Platform, validatedURL) } } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) diff --git a/backend/internal/service/openai_gateway_protocol_fallback.go b/backend/internal/service/openai_gateway_protocol_fallback.go deleted file mode 100644 index c09913a..0000000 --- a/backend/internal/service/openai_gateway_protocol_fallback.go +++ /dev/null @@ -1,362 +0,0 @@ -package service - -import ( - "context" - "errors" - "net/http" - "net/url" - "regexp" - "strings" - - "github.com/gin-gonic/gin" - "github.com/tidwall/gjson" -) - -// MixedLocalRequestError is a local client limitation, without any upstream -// attempt or account-health consequence. -type MixedLocalRequestError struct{} - -func (MixedLocalRequestError) Error() string { return "Mixed supports text HTTP requests only" } - -func IsMixedLocalRequestError(err error) bool { - var limitation MixedLocalRequestError - return errors.As(err, &limitation) -} - -func mixedHasMediaContent(value gjson.Result) bool { - if value.IsArray() { - for _, item := range value.Array() { - if mixedHasMediaContent(item) { - return true - } - } - return false - } - if !value.IsObject() { - return false - } - switch value.Get("type").String() { - case "image", "image_url", "input_image", "input_audio", "audio", "output_audio", "video", "video_url", "input_video", "document", "file", "input_file": - return true - } - return mixedHasMediaContent(value.Get("content")) -} - -func writeMixedLocalRequestError(c *gin.Context, clientProtocol string) error { - err := MixedLocalRequestError{} - if clientProtocol == APIProtocolAnthropic { - writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) - } else { - writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) - } - return err -} - -// Protocol refusals describe this request/model, never an account capability. -// Keep the original client body and visited protocols local to one forwarding -// call so model mapping and conversion start fresh on every attempt. -type openAIProtocolFallbackKey struct{} - -type openAIProtocolFallbackState struct { - clientCtx context.Context - visited map[string]bool - forced string - order []string -} - -type openAIProtocolRefusal struct{ next string } - -func (e *openAIProtocolRefusal) Error() string { - return "upstream explicitly refused the request protocol" -} - -func openAIProtocolFallbackFromContext(ctx context.Context) *openAIProtocolFallbackState { - state, _ := ctx.Value(openAIProtocolFallbackKey{}).(*openAIProtocolFallbackState) - return state -} - -func genericOpenAIProtocolFallbackAccount(account *Account) bool { - return account.IsMixed() && account.Type == AccountTypeAPIKey -} - -func openAIProtocolFallbackEligible(c *gin.Context, account *Account, body []byte) bool { - if !genericOpenAIProtocolFallbackAccount(account) || c == nil || c.Request == nil || - c.Request.Method != http.MethodPost || - GetOpenAIClientTransport(c) == OpenAIClientTransportWS || isOpenAIResponsesCompactPath(c) || - strings.EqualFold(c.GetHeader("Upgrade"), "websocket") { - return false - } - path := strings.TrimRight(c.Request.URL.Path, "/") - if !strings.HasSuffix(path, "/responses") && !strings.HasSuffix(path, "/chat/completions") && !strings.HasSuffix(path, "/messages") { - return false - } - model := account.GetMappedModel(gjson.GetBytes(body, "model").String()) - if IsImageGenerationIntent(c.Request.URL.Path, model, body) || gjson.GetBytes(body, "context_management").Exists() || gjson.GetBytes(body, "audio").Exists() || gjson.GetBytes(body, "web_search_options").Exists() { - return false - } - for _, modality := range gjson.GetBytes(body, "modalities").Array() { - if modality.String() != "text" { - return false - } - } - for _, tool := range gjson.GetBytes(body, "tools").Array() { - switch tool.Get("type").String() { - case "image_generation", "web_search", "web_search_preview": - return false - } - if strings.HasPrefix(tool.Get("type").String(), "web_search_") { - return false - } - } - for _, field := range []string{"messages", "input", "system"} { - if mixedHasMediaContent(gjson.GetBytes(body, field)) { - return false - } - } - return true -} - -// Explicit text evidence is required for all new transitions. A status or -// model_not_available code alone says nothing about a model's wire protocol. -func protocolRefusalPattern(name string) *regexp.Regexp { - name = `(?:the\s+)?(?:openai[\s_]+)?(?:` + name + `)(?:\s+(?:api|protocol|endpoint))?` - end := `(?:\s*[.,:;!]|\s*$|\s+(?:for|by|on)\b)` - return regexp.MustCompile(`(?:does not support|doesn't support|not support|unsupported)\s+` + name + end + `|` + name + `\s+(?:is\s+)?(?:not supported|unsupported|not implemented)` + end) -} - -var ( - responsesProtocolRefusal = protocolRefusalPattern(`responses|/v1/responses|/responses`) - chatProtocolRefusal = protocolRefusalPattern(`chat completions|chat_completions|(?:/v1/)?chat/completions`) - anthropicProtocolRefusal = protocolRefusalPattern(`anthropic(?:[\s_]+messages)?|messages|/v1/messages|/messages`) -) - -func isExplicitOpenAIProtocolRefusal(status int, body []byte, protocol string) bool { - if status != http.StatusBadRequest && status != http.StatusNotFound && status != http.StatusMethodNotAllowed { - return false - } - message := strings.ToLower(strings.TrimSpace(extractUpstreamErrorMessage(body))) - if message == "" { - // Express-style route errors may be plain text. Accept only an exact - // explicit route refusal; an unstructured bare 404 remains terminal. - message = strings.ToLower(strings.TrimSpace(string(body))) - path := protocolUpstreamEndpoint(protocol) - return message == "cannot post "+path || message == "cannot post "+strings.TrimPrefix(path, "/v1") - } - // A rejected parameter/schema/tool is not evidence that the wire protocol - // is unavailable, even when the error also names that protocol. - for _, field := range []string{"error.param", "param", "error.parameter", "parameter"} { - if value := gjson.GetBytes(body, field); value.Exists() && value.Type != gjson.Null && value.String() != "" { - return false - } - } - for _, term := range []string{"parameter", "argument", "schema", "tool", "field", "model_not_found", "unknown model", "model does not exist"} { - if strings.Contains(message, term) || strings.Contains(strings.ToLower(extractUpstreamErrorCode(body)), term) { - return false - } - } - path := protocolUpstreamEndpoint(protocol) - if message == "cannot post "+path || message == "cannot post "+strings.TrimPrefix(path, "/v1") { - return true - } - switch protocol { - case APIProtocolResponses: - return responsesProtocolRefusal.MatchString(message) - case APIProtocolChatCompletions: - return chatProtocolRefusal.MatchString(message) - case APIProtocolAnthropic: - return anthropicProtocolRefusal.MatchString(message) - } - return false -} - -func (s *OpenAIGatewayService) protocolRefusalFallback(ctx context.Context, c *gin.Context, account *Account, protocol string, status int, body []byte, legacy bool) error { - state := openAIProtocolFallbackFromContext(ctx) - if state == nil || state.clientCtx.Err() != nil || c == nil || c.Request == nil || - c.Request.Context().Err() != nil || IsResponseCommitted(c) || - openAIStreamClientOutputStarted(c, false) || openAIStreamHeaderKeepaliveFailed(c) || - !isExplicitOpenAIProtocolRefusal(status, body, protocol) { - return nil - } - state.visited[protocol] = true - for _, next := range state.order { - if state.visited[next] { - continue - } - if next == APIProtocolAnthropic { - if !crossProtocolConversionAllowedFromContext(c) { - continue - } - // No alternate domain, inferred provider endpoint, or auth retry. - if _, err := s.nativeAnthropicTargetURL(account); err != nil { - continue - } - } - return &openAIProtocolRefusal{next: next} - } - return nil -} - -func forcedOpenAIResponsesProtocol(ctx context.Context) bool { - state := openAIProtocolFallbackFromContext(ctx) - return state != nil && state.forced == APIProtocolResponses -} - -func (s *OpenAIGatewayService) forwardWithProtocolFallback(ctx context.Context, c *gin.Context, account *Account, body []byte, clientProtocol, promptCacheKey, defaultMappedModel string, initial func(context.Context) (*OpenAIForwardResult, error)) (*OpenAIForwardResult, error) { - if !account.IsMixed() { - return initial(ctx) - } - requestedModel := gjson.GetBytes(body, "model").String() - upstreamModel := normalizeOpenAIModelForUpstream(account, resolveOpenAIForwardModel(account, requestedModel, defaultMappedModel)) - if err := CheckModelScope(ctx, requestedModel, upstreamModel); err != nil { - return nil, err - } - if err := validateMixedAccount(account); err != nil { - return nil, err - } - if !openAIProtocolFallbackEligible(c, account, body) { - return nil, writeMixedLocalRequestError(c, clientProtocol) - } - // Official API imports reuse the original provider dispatcher, including - // native paths and its explicit protocol settings, not relay trial order. - if mixedOfficialProviderView(account) != nil { - return initial(ctx) - } - // Reuse the client's native protocol before converting after an explicit - // refusal. Re-entering an attempt never starts a fresh budget. - if openAIProtocolFallbackFromContext(ctx) != nil { - return initial(ctx) - } - order := []string{APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} - // Cursor may post a Responses body to the Chat URL. Preserve the existing - // native dispatcher's shape detection before selecting an upstream protocol. - responsesShapedChat := clientProtocol == APIProtocolChatCompletions && - !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() - switch clientProtocol { - case APIProtocolResponses: - order = []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} - case APIProtocolAnthropic: - order = []string{APIProtocolAnthropic, APIProtocolChatCompletions, APIProtocolResponses} - } - if responsesShapedChat { - order = []string{APIProtocolResponses, APIProtocolChatCompletions, APIProtocolAnthropic} - } - state := &openAIProtocolFallbackState{clientCtx: ctx, visited: make(map[string]bool, 3), order: order} - ctx = context.WithValue(ctx, openAIProtocolFallbackKey{}, state) - beginUpstreamResponseModelObservation(c) - setCodexToolNameReverse(c, nil) - protocol := order[0] - for attempts := 0; attempts < len(order); attempts++ { - if err := state.clientCtx.Err(); err != nil { - return nil, err - } - if err := c.Request.Context().Err(); err != nil { - return nil, err - } - state.forced = protocol - state.visited[protocol] = true - SetActualOpenAIUpstreamEndpoint(c, protocolUpstreamEndpoint(protocol)) - var result *OpenAIForwardResult - var err error - switch clientProtocol { - case APIProtocolResponses: - switch protocol { - case APIProtocolResponses: - result, err = s.forwardOpenAIResponses(ctx, c, account, body) - case APIProtocolChatCompletions: - result, err = s.forwardResponsesViaRawChatCompletions(ctx, c, account, body) - case APIProtocolAnthropic: - result, err = s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, "") - } - case APIProtocolChatCompletions: - switch protocol { - case APIProtocolResponses: - result, err = s.forwardOpenAIChatCompletions(ctx, c, account, body, promptCacheKey, defaultMappedModel) - case APIProtocolChatCompletions: - chatBody := body - if responsesShapedChat { - chatBody, err = s.convertResponsesShapedChatBody(body) - if err != nil { - return nil, err - } - } - result, err = s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel) - case APIProtocolAnthropic: - chatBody := body - if responsesShapedChat { - chatBody, err = s.convertResponsesShapedChatBody(body) - if err != nil { - return nil, err - } - } - result, err = s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, chatBody, defaultMappedModel) - } - case APIProtocolAnthropic: - if protocol != APIProtocolAnthropic && !crossProtocolConversionAllowedFromContext(c) { - return nil, CrossProtocolConversionError{} - } - switch protocol { - case APIProtocolResponses: - result, err = s.forwardOpenAIAnthropic(ctx, c, account, body, promptCacheKey, defaultMappedModel) - case APIProtocolChatCompletions: - result, err = s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel) - case APIProtocolAnthropic: - result, err = s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel) - } - } - SetActualOpenAIUpstreamEndpoint(c, protocolUpstreamEndpoint(protocol)) - if errors.Is(err, ErrModelScopeDenied) { - return result, err - } - if clientErr := c.Request.Context().Err(); clientErr != nil { - if result != nil { - result.ClientDisconnect = true - } - return result, clientErr - } - if clientErr := state.clientCtx.Err(); clientErr != nil { - if result != nil { - result.ClientDisconnect = true - } - return result, clientErr - } - var refusal *openAIProtocolRefusal - if !errors.As(err, &refusal) { - return result, err - } - protocol = refusal.next - } - return nil, errors.New("mixed protocol attempts exhausted") -} - -func protocolUpstreamEndpoint(protocol string) string { - switch protocol { - case APIProtocolChatCompletions: - return "/v1/chat/completions" - case APIProtocolAnthropic: - return "/v1/messages" - default: - return "/v1/responses" - } -} - -// Generic credentials reuse their configured origin for Messages. Explicit -// existing protocol bases may change paths, but may not move the secret to a -// different origin. CN/adaptive base resolution stays in the account helpers. -func genericOpenAIAnthropicBaseURL(account *Account) (string, error) { - base := strings.TrimSpace(account.GetCredential("base_url")) - configured, err := url.Parse(base) - if err != nil || configured.Host == "" || configured.User != nil { - return "", errors.New("missing or invalid configured base_url for anthropic fallback") - } - selected := base - if bases, ok := account.Credentials["api_base_urls"].(map[string]any); ok { - if value, ok := bases[APIProtocolAnthropic].(string); ok && strings.TrimSpace(value) != "" { - selected = strings.TrimSpace(value) - } - } - target, err := url.Parse(selected) - if err != nil || target.User != nil || !strings.EqualFold(target.Scheme, configured.Scheme) || !strings.EqualFold(target.Host, configured.Host) { - return "", errors.New("anthropic fallback must use the configured base_url origin") - } - return normalizeMixedBaseURL(target.String()), nil -} diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 9e50781..018b0e7 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -239,11 +239,11 @@ func deepSeekCallIDScope(platform, model string) bool { // DeepSeek, either because the account/group platform is DeepSeek or the model // name starts with deepseek. Those upstreams reject a repeated call_id. func deepSeekDuplicateCallIDRewrite(account *Account, body []byte) bool { - platform := "" - if account != nil { - platform = account.openAIWirePlatform() + model := gjson.GetBytes(body, "model").String() + if account == nil { + return deepSeekCallIDScope("", model) } - return deepSeekCallIDScope(platform, gjson.GetBytes(body, "model").String()) + return deepSeekCallIDScope(account.Platform, model) } func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool { diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index e8ff7f5..53b0b3b 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -442,12 +442,6 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. } finalizeStream := func() (*openaiStreamingResult, error) { observeClientCancellation() - if account != nil && account.IsMixed() && !sawTerminalEvent { - flushPending("Client disconnected during final flush, returning collected usage") - cause := ErrOpenAIUpstreamStreamTruncated - writeMixedStreamFailure(c, APIProtocolResponses, cause) - return resultWithUsage(), cause - } if stageFirstOutput && eventInProgress { // EOF dispatches the final SSE event even without a trailing blank line. completeGuardedEvent(true) @@ -509,12 +503,6 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. if scanErr == nil { return nil, nil, false } - if account != nil && account.IsMixed() && !sawTerminalEvent { - flushPending("Client disconnected during read-error flush, returning collected usage") - cause := mixedStreamReadFailure(scanErr) - writeMixedStreamFailure(c, APIProtocolResponses, cause) - return resultWithUsage(), cause, true - } if clientDisconnected { if sawTerminalEvent { result, err := finalizeStream() @@ -613,34 +601,6 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. suppressCurrentEvent = true } observer.ObserveOpenAIFrame(frame) - if account != nil && account.IsMixed() && strings.TrimSpace(data) == "[DONE]" && !sawTerminalEvent { - flushPending("Client disconnected during incomplete-stream flush, returning collected usage") - streamEarlyErr = ErrOpenAIUpstreamStreamTruncated - writeMixedStreamFailure(c, APIProtocolResponses, streamEarlyErr) - return - } - if account != nil && account.IsMixed() && mixedPayloadIsError(dataBytes, eventType) { - s.parseSSEUsageBytesWithType(dataBytes, eventType, usage) - // Keep the existing pre-generation request-error billing rule on - // this early Mixed exit too. Never make partial/policy output free. - outputStarted := responsesSemanticOutputSeen || openAIStreamClientOutputStarted(c, clientOutputStarted) || - len(frame.root.Get("response.output").Array()) > 0 - if !outputStarted && !mixedCCPayloadHasPolicy(dataBytes) && - isOpenAINonBillableRequestError(extractOpenAISSEErrorMessage(dataBytes), dataBytes) { - nonBillableUpstreamError = true - } - flushPending("Client disconnected during failure flush, returning collected usage") - streamEarlyErr = fmt.Errorf("upstream Responses stream failed") - writeMixedStreamFailure(c, APIProtocolResponses, streamEarlyErr) - return - } - if account != nil && account.IsMixed() && strings.TrimSpace(data) != "[DONE]" && - openAIStreamEventIsTerminalWithType(data, eventType) && !mixedValidResponsesTerminal(dataBytes, eventType) { - flushPending("Client disconnected during invalid-terminal flush, returning collected usage") - streamEarlyErr = ErrOpenAIUpstreamStreamTruncated - writeMixedStreamFailure(c, APIProtocolResponses, streamEarlyErr) - return - } // 初始上游 data 的 type 只解析一次:原始值保持终止事件的精确匹配,规范化值供后续分支复用。 if openAIStreamEventIsTerminalWithType(data, eventType) { sawTerminalEvent = true @@ -1822,22 +1782,8 @@ func openAICacheCreationTokensFromUsage(value gjson.Result) int { } func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { - var body []byte - var err error - if account != nil && account.IsMixed() { - body, err = s.readMixedUpstreamBody(resp) - } else { - body, err = ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) - } + body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { - if account != nil && account.IsMixed() { - usage := s.parseSSEUsageFromBody(string(body)) - if jsonUsage, ok := extractOpenAIUsageFromJSONBytes(body); ok { - *usage = jsonUsage - } - writeMixedStreamFailure(c, APIProtocolResponses, err) - return &openaiNonStreamingResult{OpenAIUsage: usage, usage: usage}, err - } return nil, err } if upstreamFinancialFailureEnvelope(body) { @@ -1884,16 +1830,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r } usageValue, usageOK := extractOpenAIUsageFromJSONBytes(body) - if account != nil && account.IsMixed() && (mixedPayloadIsError(body, "") || !gjson.ValidBytes(body) || - (gjson.GetBytes(body, "status").String() != "completed" && gjson.GetBytes(body, "status").String() != "incomplete") || - !gjson.GetBytes(body, "output").IsArray()) { - cause := fmt.Errorf("upstream Responses response failed or invalid") - writeMixedStreamFailure(c, APIProtocolResponses, cause) - return &openaiNonStreamingResult{OpenAIUsage: &usageValue, usage: &usageValue}, cause - } - // Completion evidence is independent of metering. Some compatible - // providers omit usage; preserve their valid result and existing warning. - if !usageOK && (account == nil || !account.IsMixed()) { + if !usageOK { if bodyLooksLikeSSE { return s.handleSSEToJSON(resp, c, account, body, originalModel, mappedModel) } @@ -1905,11 +1842,6 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r return nil, err } } - if account != nil && account.IsMixed() { - cause := fmt.Errorf("parse response: invalid json response") - writeMixedStreamFailure(c, APIProtocolResponses, cause) - return &openaiNonStreamingResult{OpenAIUsage: &usageValue, usage: &usageValue}, cause - } if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices && account != nil && !IsResponseCommitted(c) { // Invalid JSON or missing usage is not evidence of credential failure. // Record a fixed error without classifying or retaining arbitrary body text. @@ -1998,35 +1930,6 @@ func bodyHasSSEFraming(body []byte) bool { func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, account *Account, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { bodyText := string(body) terminalType, terminalPayload, terminalOK := extractOpenAISSETerminalEvent(bodyText) - if account != nil && account.IsMixed() { - failed := !terminalOK || !mixedValidResponsesTerminal(terminalPayload, terminalType) - // Keep the explicit SSE event name independently of payload.type: - // a vendor-specific type must never conceal a named error event. - var parser openAICompatSSEFrameParser - validateFrame := func(frame openAICompatSSEFrame, ok bool) { - if !ok { - return - } - data := []byte(frame.Data) - failed = failed || mixedPayloadIsError(data, frame.EventType) - for _, eventType := range []string{frame.EventType, effectiveOpenAISSEEventType(data, frame.EventType)} { - switch eventType { - case "response.completed", "response.done", "response.incomplete": - failed = failed || !mixedValidResponsesTerminal(data, eventType) - } - } - } - for _, line := range strings.Split(bodyText, "\n") { - validateFrame(parser.AddLine(strings.TrimRight(line, "\r"))) - } - validateFrame(parser.Finish()) - if failed { - cause := fmt.Errorf("upstream Responses stream failed or ended without terminal event") - usage := s.parseSSEUsageFromBody(bodyText) - writeMixedStreamFailure(c, APIProtocolResponses, cause) - return &openaiNonStreamingResult{OpenAIUsage: usage, usage: usage}, cause - } - } if terminalOK && (terminalType == "response.failed" || terminalType == "error") { msg := extractOpenAISSEErrorMessage(terminalPayload) if msg == "" { @@ -2042,12 +1945,6 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte return nil, s.writeOpenAINonStreamingProtocolError(resp, c, msg) } finalResponse, ok := extractCodexFinalResponse(bodyText) - if account != nil && account.IsMixed() { - // Return the same terminal response whose structure was validated, - // not the legacy helper's first potentially conflicting terminal. - finalResponse = []byte(gjson.GetBytes(terminalPayload, "response").Raw) - ok = true - } usage := s.parseSSEUsageFromBody(bodyText) if ok { diff --git a/backend/internal/service/openai_gateway_responses_anthropic_native.go b/backend/internal/service/openai_gateway_responses_anthropic_native.go index 2e2b704..d38389b 100644 --- a/backend/internal/service/openai_gateway_responses_anthropic_native.go +++ b/backend/internal/service/openai_gateway_responses_anthropic_native.go @@ -126,20 +126,14 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( if err := s.repriceOpenAIFinalAttempt(ctx, account, forwardedBody, billingModel, upstreamModel); err != nil { return nil, err } - resp, err := s.doOpenAITextUpstream(ctx, c, upstreamReq, proxyURL, account) + resp, err := s.doOpenAIUpstream(upstreamReq, proxyURL, account) if err != nil { return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { - respBody, upstreamMsg, readErr := s.readOpenAIProtocolError(resp, account) - if readErr != nil { - return nil, readErr - } - if fallbackErr := s.protocolRefusalFallback(ctx, c, account, APIProtocolAnthropic, resp.StatusCode, respBody, false); fallbackErr != nil { - return nil, fallbackErr - } + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { return nil, foErr } @@ -148,9 +142,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaNativeAnthropic( } if clientStream { - return s.handleResponsesStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, clientToolMapping, account.IsMixed()) + return s.handleResponsesStreamingFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, clientToolMapping) } - return s.handleResponsesBufferedFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, clientToolMapping, account.IsMixed()) + return s.handleResponsesBufferedFromNativeAnthropic(resp, c, originalModel, billingModel, upstreamModel, reasoningEffort, startTime, clientToolMapping) } // handleResponsesBufferedFromNativeAnthropic reads Anthropic SSE events, assembles @@ -164,10 +158,8 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( reasoningEffort *string, startTime time.Time, clientToolMapping apicompat.ResponsesClientToolMapping, - mixed ...bool, ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") - strictCompletion := len(mixed) > 0 && mixed[0] scanner := bufio.NewScanner(resp.Body) maxLineSize := defaultMaxLineSize @@ -178,12 +170,6 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( var finalResp *apicompat.AnthropicResponse var usage ClaudeUsage - failMixed := func(cause error) (*OpenAIForwardResult, error) { - writeMixedStreamFailure(c, APIProtocolResponses, cause) - result := s.nativeAnthropicStreamResult(c, resp, &usage, nil, false, originalModel, billingModel, upstreamModel, reasoningEffort, startTime) - result.Stream = false - return result, cause - } // 读间隔上限:上游挂住 SSE 时中止组装(缓冲路径尚未提交响应头,可回 502)。 streamInterval := s.anthropicNativeStreamInterval() @@ -200,9 +186,6 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( } onIdle := func() (*OpenAIForwardResult, error) { _ = resp.Body.Close() - if strictCompletion { - return failMixed(errAnthropicNativeStreamIdle) - } logger.L().Warn("openai responses via native anthropic buffered: data interval timeout", zap.String("request_id", requestID), zap.Duration("interval", streamInterval), @@ -214,9 +197,6 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( for { line, rerr := pump.next() if rerr != nil { - if strictCompletion { - return failMixed(mixedStreamReadFailure(rerr)) - } if errors.Is(rerr, errAnthropicNativeStreamIdle) { return onIdle() } @@ -224,16 +204,13 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( break } // SSE 规范允许 `event:xxx`(冒号后无空格):Kimi 等上游返回紧凑格式。 - eventType, ok := extractOpenAISSEEventLine(line) + _, ok := extractOpenAISSEEventLine(line) if !ok { continue } dataLine, rerr := pump.next() if rerr != nil { - if strictCompletion { - return failMixed(mixedStreamReadFailure(rerr)) - } if errors.Is(rerr, errAnthropicNativeStreamIdle) { return onIdle() } @@ -247,17 +224,8 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( var event apicompat.AnthropicStreamEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { - if strictCompletion { - return failMixed(fmt.Errorf("invalid upstream Anthropic event: %w", err)) - } continue } - if strictCompletion && mixedPayloadIsError([]byte(payload), eventType) { - if event.Usage != nil { - mergeAnthropicUsage(&usage, *event.Usage) - } - return failMixed(fmt.Errorf("upstream Anthropic stream failed")) - } if event.Type == "message_start" && event.Message != nil { finalResp = event.Message @@ -289,15 +257,9 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic( } } } - if strictCompletion && event.Type == "message_stop" { - break - } } if finalResp == nil { - if strictCompletion { - return failMixed(ErrOpenAIUpstreamStreamTruncated) - } writeResponsesError(c, http.StatusBadGateway, "server_error", "Upstream stream ended without a response") return nil, fmt.Errorf("upstream stream ended without response") } @@ -355,10 +317,8 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( reasoningEffort *string, startTime time.Time, clientToolMapping apicompat.ResponsesClientToolMapping, - mixed ...bool, ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") - strictCompletion := len(mixed) > 0 && mixed[0] if s.responseHeaderFilter != nil { responseheaders.WriteFilteredHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter) @@ -402,10 +362,6 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( ClientDisconnect: clientDisconnected, } } - failMixed := func(cause error) (*OpenAIForwardResult, error) { - writeMixedStreamFailure(c, APIProtocolResponses, cause) - return resultWithUsage(), cause - } // 读间隔上限:上游挂住 SSE(不发数据也不断连)时结束转换循环。上游 ctx 为 // WithoutCancel 且 http.Client 无整体 Timeout,无此界限则 scanner.Scan() @@ -424,9 +380,6 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( } onIdle := func() (*OpenAIForwardResult, error) { _ = resp.Body.Close() - if strictCompletion { - return failMixed(errAnthropicNativeStreamIdle) - } logger.L().Warn("openai responses via native anthropic stream: data interval timeout", zap.String("request_id", requestID), zap.Duration("interval", streamInterval), @@ -482,25 +435,19 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( for { line, rerr := pump.next() if rerr != nil { - if strictCompletion { - return failMixed(mixedStreamReadFailure(rerr)) - } if errors.Is(rerr, errAnthropicNativeStreamIdle) { return onIdle() } logReadErr(rerr) break } - eventType, ok := extractOpenAISSEEventLine(line) + _, ok := extractOpenAISSEEventLine(line) if !ok { continue } dataLine, rerr := pump.next() if rerr != nil { - if strictCompletion { - return failMixed(mixedStreamReadFailure(rerr)) - } if errors.Is(rerr, errAnthropicNativeStreamIdle) { return onIdle() } @@ -514,25 +461,10 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic( var event apicompat.AnthropicStreamEvent if err := json.Unmarshal([]byte(payload), &event); err != nil { - if strictCompletion { - return failMixed(fmt.Errorf("invalid upstream Anthropic event: %w", err)) - } continue } - if strictCompletion && mixedPayloadIsError([]byte(payload), eventType) { - if event.Usage != nil { - mergeAnthropicUsage(&usage, *event.Usage) - } - return failMixed(fmt.Errorf("upstream Anthropic stream failed")) - } - if strictCompletion && event.Type == "message_stop" && !state.CreatedSent { - return failMixed(ErrOpenAIUpstreamStreamTruncated) - } processAnthropicEvent(&event) - if strictCompletion && event.Type == "message_stop" { - break - } } // Finalize state machine(客户端已断开时仍推进,保证 usage 汇总完整;仅在 diff --git a/backend/internal/service/openai_gateway_responses_chat_fallback.go b/backend/internal/service/openai_gateway_responses_chat_fallback.go index d0fcb28..3b16157 100644 --- a/backend/internal/service/openai_gateway_responses_chat_fallback.go +++ b/backend/internal/service/openai_gateway_responses_chat_fallback.go @@ -70,11 +70,11 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( billingModel := resolveOpenAIForwardModel(account, originalModel, "") upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) - if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil && !account.IsMixed() { + if err := validateGPT61SolCompatRequest(body, upstreamModel); err != nil { writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err } - if !account.IsMixed() && openai.IsGPT61SolModelSpelling(upstreamModel) && len(effectiveTools) > 0 { + if openai.IsGPT61SolModelSpelling(upstreamModel) && len(effectiveTools) > 0 { err := fmt.Errorf("gpt-6.1-sol requires Responses for tool calls; this account only supports Chat Completions") writeOpenAIResponsesFallbackError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, err @@ -88,11 +88,6 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( if err != nil { return nil, fmt.Errorf("marshal chat completions fallback request: %w", err) } - if account.IsMixed() { - if normalizedBody, normalized := NormalizeGLMOpenAIReasoningEffort(chatBody, upstreamModel); normalized { - chatBody = normalizedBody - } - } chatBody, err = s.applyOpenAIFastPolicyToBody(ctx, account, upstreamModel, chatBody) if err != nil { var blocked *OpenAIFastBlockedError @@ -133,13 +128,7 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 400 { - respBody, upstreamMsg, readErr := s.readOpenAIProtocolError(resp, account) - if readErr != nil { - return nil, readErr - } - if fallbackErr := s.protocolRefusalFallback(ctx, c, account, APIProtocolChatCompletions, resp.StatusCode, respBody, false); fallbackErr != nil { - return nil, fallbackErr - } + respBody, upstreamMsg := s.readOpenAIUpstreamError(resp) if foErr := s.failoverOpenAIUpstreamHTTPError(ctx, c, account, resp, respBody, upstreamMsg, upstreamModel); foErr != nil { return nil, foErr } @@ -147,9 +136,9 @@ func (s *OpenAIGatewayService) forwardResponsesViaRawChatCompletions( } if clientStream { - return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, localShellTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime, account.IsMixed()) + return s.streamChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, localShellTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } - return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, localShellTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime, account.IsMixed()) + return s.bufferChatCompletionsAsResponses(c, resp, originalModel, customTools, functionTools, toolSearch, namespaceTools, localShellTools, billingModel, upstreamModel, reasoningEffort, serviceTier, startTime) } func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( @@ -166,16 +155,10 @@ func (s *OpenAIGatewayService) bufferChatCompletionsAsResponses( reasoningEffort *string, serviceTier *string, startTime time.Time, - mixed ...bool, ) (*OpenAIForwardResult, error) { requestID := resp.Header.Get("x-request-id") - ccResp, usage, err := s.readCCUpstreamJSONResponse(c, resp, writeOpenAIResponsesFallbackError, mixed...) + ccResp, usage, err := s.readCCUpstreamJSONResponse(c, resp, writeOpenAIResponsesFallbackError) if err != nil { - if len(mixed) > 0 && mixed[0] { - result := mixedPartialForwardResult(c, resp, usage, originalModel, billingModel, upstreamModel, false, startTime) - result.NonBillableUpstreamError = mixedCCErrorNonBillable(err) - return result, err - } return nil, err } responsesResp := apicompat.ChatCompletionsResponseToResponsesWithLocalShell(ccResp, originalModel, customTools, functionTools, toolSearch, namespaceTools, localShellTools) @@ -218,9 +201,7 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( reasoningEffort *string, serviceTier *string, startTime time.Time, - mixedAccount ...bool, ) (*OpenAIForwardResult, error) { - mixed := len(mixedAccount) > 0 && mixedAccount[0] requestID := resp.Header.Get("x-request-id") writeStreamHeaders := s.newStreamHeaderWriter(c, resp.Header) @@ -258,16 +239,13 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( c.Writer.Flush() } - scan := s.scanCCStream(c, resp, "openai responses chat fallback", requestID, startTime, mixed, func(chunk *apicompat.ChatCompletionsChunk) { + scan := s.scanCCStream(c, resp, "openai responses chat fallback", requestID, startTime, func(chunk *apicompat.ChatCompletionsChunk) { events := apicompat.ChatCompletionsChunkToResponsesEvents(chunk, state) s.cacheReasoningItemsFromEvents(events) writeEvents(events) }) if scan.Err != nil { - if mixed { - writeMixedStreamFailure(c, APIProtocolResponses, scan.Err) - } return &OpenAIForwardResult{ RequestID: requestID, UpstreamHeaders: resp.Header, @@ -288,10 +266,6 @@ func (s *OpenAIGatewayService) streamChatCompletionsAsResponses( }, fmt.Errorf("stream usage incomplete: %w", scan.Err) } if err := state.ValidateToolCallArguments(); err != nil { - if mixed { - err = s.recordMixedCCFailure(c, resp, newMixedCCFailure(c, nil, "", "invalid_tool_arguments", true, nil)) - writeMixedStreamFailure(c, APIProtocolResponses, err) - } return &OpenAIForwardResult{ RequestID: requestID, UpstreamHeaders: resp.Header, diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index eff5b34..34e9bc1 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -304,7 +304,7 @@ func (s *OpenAIGatewayService) SelectAccountForTokenCount( // handler 调度入口仍需导出,保持导出名。) func NormalizeOpenAICompatiblePlatform(platform string) string { switch platform { - case PlatformMixed, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformGemini, PlatformMiniMax, PlatformCommandCode, PlatformCline: + case PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformGemini, PlatformMiniMax, PlatformCline, PlatformCommandCode: return platform default: return PlatformOpenAI @@ -1047,8 +1047,7 @@ func (s *OpenAIGatewayService) tryStickySessionHit(ctx context.Context, groupID // 验证账号是否可用于当前请求 // Verify account is usable for current request - if !openAIAccountAllowsModelScope(ctx, account, requestedModel, requireCompact) || - !isOpenAICompatibleAccountEligibleForRequest(ctx, account, platform, requestedModel, false, requiredCapability) { + if !isOpenAICompatibleAccountEligibleForRequest(ctx, account, platform, requestedModel, false, requiredCapability) { return nil } if !parentHealthyForShadow(account, s.parentAccountLookup(ctx)) { @@ -1347,8 +1346,7 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex account, err := s.getSchedulableAccount(ctx, accountID) if err != nil || account == nil || shouldClearStickySession(account, requestedModel) { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) - } else if openAIAccountAllowsModelScope(ctx, account, requestedModel, requireCompact) && - isOpenAICompatibleAccountEligibleForRequest(ctx, account, platform, requestedModel, false, requiredCapability) { + } else if isOpenAICompatibleAccountEligibleForRequest(ctx, account, platform, requestedModel, false, requiredCapability) { account = s.recheckSelectedOpenAIAccountFromDB(ctx, account, groupID, platform, requestedModel, requireCompact, requiredCapability) if account == nil { _ = s.deleteStickySessionAccountID(ctx, groupID, sessionHash) @@ -1445,10 +1443,6 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex filterStats.exclude("excluded") continue } - if !openAIAccountAllowsModelScope(ctx, acc, requestedModel, requireCompact) { - filterStats.exclude("model_scope_denied") - continue - } // Scheduler snapshots can be temporarily stale (bucket rebuild is throttled); // re-check schedulability here so recently rate-limited/overloaded accounts // are not selected again before the bucket is rebuilt. @@ -1479,7 +1473,6 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex for i := range overdraft { acc := &overdraft[i] if isExcluded(acc.ID) || - !openAIAccountAllowsModelScope(ctx, acc, requestedModel, requireCompact) || !isOpenAICompatibleAccountEligibleForRequest(ctx, acc, platform, requestedModel, false, requiredCapability) || !parentHealthyForShadow(acc, parentLookupL2) || s.isOpenAIAccountRequestRuntimeBlocked(acc, requestedModel, requireCompact) || @@ -1785,14 +1778,7 @@ func (s *OpenAIGatewayService) resolveFreshSchedulableOpenAIAccountBeforeProfit( fresh = current } - // selectBestAccount checks compact support later to preserve its failure - // diagnostics, but scope must already use the actual compact mapping. - checkCompactScope := requireCompact - if len(scopeCompact) > 0 { - checkCompactScope = scopeCompact[0] - } - if !openAIAccountAllowsModelScope(ctx, fresh, requestedModel, checkCompactScope) || - !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, fresh, platform, requestedModel, requireCompact, requiredCapability) { + if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, fresh, platform, requestedModel, requireCompact, requiredCapability) { return nil } if !parentHealthyForShadow(fresh, s.parentAccountLookup(ctx)) { @@ -1849,13 +1835,6 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDBBeforeProfit(ct if account == nil { return nil } - checkCompactScope := requireCompact - if len(scopeCompact) > 0 { - checkCompactScope = scopeCompact[0] - } - if !openAIAccountAllowsModelScope(ctx, account, requestedModel, checkCompactScope) { - return nil - } platform = NormalizeOpenAICompatiblePlatform(platform) if s.schedulerSnapshot == nil || s.accountRepo == nil { if s.openAIGroupRequiresPrivacySet(ctx, groupID) && !account.IsPrivacySet() { @@ -1908,8 +1887,7 @@ func (s *OpenAIGatewayService) recheckSelectedOpenAIAccountFromDBBeforeProfit(ct if s.openAIGroupRequiresPrivacySet(ctx, groupID) && !latest.IsPrivacySet() { return nil } - if !openAIAccountAllowsModelScope(ctx, latest, requestedModel, checkCompactScope) || - !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, latest, platform, requestedModel, requireCompact, requiredCapability) { + if !isOpenAICompatibleAccountEligibleForRequestBeforeProfit(ctx, latest, platform, requestedModel, requireCompact, requiredCapability) { return nil } if !parentHealthyForShadow(latest, s.parentAccountLookup(ctx)) { diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 62b1351..4b3f100 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -576,7 +576,7 @@ func NewOpenAIGatewayService( nil, "service.openai_gateway", ), - httpUpstream: withModelScopeUpstream(httpUpstream), + httpUpstream: httpUpstream, deferredService: deferredService, openAITokenProvider: openAITokenProvider, grokTokenProvider: grokTokenProvider, diff --git a/backend/internal/service/openai_gateway_stability_backport_test.go b/backend/internal/service/openai_gateway_stability_backport_test.go index 4fd63c9..2171b83 100644 --- a/backend/internal/service/openai_gateway_stability_backport_test.go +++ b/backend/internal/service/openai_gateway_stability_backport_test.go @@ -17,19 +17,6 @@ import ( "github.com/tidwall/gjson" ) -func TestGatewayStabilityImageBridgePlatformGuard(t *testing.T) { - svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{CodexImageGenerationBridgeEnabled: true}}} - for _, platform := range []string{PlatformOpenAI, PlatformKimi, PlatformDeepseek, PlatformMiniMax, PlatformZhipu, PlatformMixed, PlatformAnthropic} { - t.Run(platform, func(t *testing.T) { - account := &Account{Platform: platform, Extra: map[string]any{featureKeyCodexImageGenerationBridge: true}} - require.Equal(t, platform == PlatformOpenAI, svc.isCodexImageGenerationBridgeEnabled(context.Background(), account, nil)) - }) - } - require.False(t, svc.isCodexImageGenerationBridgeEnabled(context.Background(), nil, nil)) - account := &Account{Platform: PlatformOpenAI, Extra: map[string]any{featureKeyCodexImageGenerationBridge: false}} - require.False(t, svc.isCodexImageGenerationBridgeEnabled(context.Background(), account, nil)) -} - func TestGatewayStabilityOverloadedWrapping(t *testing.T) { for _, upstreamStatus := range []int{500, 502, 503, 504, 529} { status, kind, _, _ := WrapUpstreamErrorForClient(upstreamStatus, []byte(`{"error":{"type":"overloaded_error","message":"Overloaded"}}`)) @@ -122,3 +109,16 @@ func TestGatewayStabilityCompleteJSONArgumentsRemainUntouched(t *testing.T) { require.True(t, changed) require.Equal(t, value, out) } + +func TestGatewayStabilityImageBridgePlatformGuard(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{CodexImageGenerationBridgeEnabled: true}}} + for _, platform := range []string{PlatformOpenAI, PlatformKimi, PlatformDeepseek, PlatformMiniMax, PlatformZhipu, PlatformAnthropic} { + t.Run(platform, func(t *testing.T) { + account := &Account{Platform: platform, Extra: map[string]any{featureKeyCodexImageGenerationBridge: true}} + require.Equal(t, platform == PlatformOpenAI, svc.isCodexImageGenerationBridgeEnabled(context.Background(), account, nil)) + }) + } + require.False(t, svc.isCodexImageGenerationBridgeEnabled(context.Background(), nil, nil)) + account := &Account{Platform: PlatformOpenAI, Extra: map[string]any{featureKeyCodexImageGenerationBridge: false}} + require.False(t, svc.isCodexImageGenerationBridgeEnabled(context.Background(), account, nil)) +} diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index ce4a28a..689ab04 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -221,7 +221,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if s.cfg != nil { multiplier = s.cfg.Default.RateMultiplier } - if apiKey.GroupID != nil && apiKey.Group != nil && apiKey.Group.Platform != PlatformMixed { + if apiKey.GroupID != nil && apiKey.Group != nil { multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier) } // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。 @@ -253,19 +253,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec result.Model, ) billingModels = s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, billingModels) - billingModels = s.mixedUsageBillingModelCandidates(ctx, input, billingModels) - // Resolve each actual price candidate independently; no model-specific default - // is ever cached in the user/group override cache. - mixedRates := apiKey.GroupID != nil && apiKey.Group != nil && apiKey.Group.Platform == PlatformMixed - setMixedRate := func(model string) { - if !mixedRates { - return - } - baseMultiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplierForModel(model)) - multiplier, imageMultiplier = computePeakAwareMultipliers(apiKey, baseMultiplier, pricingAt) - videoMultiplier = resolveVideoRateMultiplier(apiKey, baseMultiplier) - } - setMixedRate(firstUsageBillingModel(billingModels)) serviceTier := "" if result.ServiceTier != nil { serviceTier = strings.TrimSpace(*result.ServiceTier) @@ -287,13 +274,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec pricingAt, modelLongContextExempt, ) - if err == nil && mixedRates && cost != nil && cost.billingModel != "" && cost.billingModel != firstUsageBillingModel(billingModels) { - // The token calculator can fall back to a later priced identity. Reprice - // only that successful candidate so the final rate and usage log agree. - billingModels = []string{cost.billingModel} - setMixedRate(cost.billingModel) - cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, pricingAt, modelLongContextExempt) - } if err != nil { if !isUsagePricingUnavailableError(err) { return err @@ -323,8 +303,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec ); responseModel != "" && !strings.EqualFold(responseModel, baselineBillingModel) { if identified, responseChannelPriced := s.hasIdentifiedOpenAIResponsePricing(ctx, responseModel, apiKey); identified { responseModels := s.filterCNProviderBillingModelCandidates(ctx, account, apiKey, usageBillingModelCandidates(responseModel)) - previousBase, previousText, previousImage, previousVideo := baseMultiplier, multiplier, imageMultiplier, videoMultiplier - setMixedRate(responseModel) responseCost, responseErr := s.calculateOpenAIRecordUsageCost( ctx, result, apiKey, responseModels, multiplier, imageMultiplier, videoMultiplier, baseMultiplier, tokens, serviceTier, longContextBillingGate, pricingAt, modelLongContextExempt, @@ -338,8 +316,6 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec baselineBillingModel, responseModel, cost, responseCost) billingModels = responseModels cost = responseCost - } else { - baseMultiplier, multiplier, imageMultiplier, videoMultiplier = previousBase, previousText, previousImage, previousVideo } } } @@ -607,40 +583,6 @@ func (s *OpenAIGatewayService) hasIdentifiedOpenAIResponsePricing(ctx context.Co return s.billingService.HasIdentifiedTokenPricing(model), false } -// Mixed protocol conversion does not make the client's model alias a pricing -// fallback. Only the actual upstream identity (and its existing normalized -// aliases) may supply the default price; unknown identities retain zero-cost -// usage recording. Explicit billing-source selections keep their old behavior. -func (s *OpenAIGatewayService) mixedUsageBillingModelCandidates(ctx context.Context, input *OpenAIRecordUsageInput, candidates []string) []string { - if input.Account == nil || !input.Account.IsMixed() || - (input.BillingModelSource != "" && input.BillingModelSource != BillingModelSourceUpstream) { - return candidates - } - result := input.Result - actualModel := firstNonEmpty(strings.TrimSpace(result.UpstreamModel), strings.TrimSpace(result.BillingModel), strings.TrimSpace(result.Model)) - // usageBillingModelCandidates also expands substring-based Codex family - // guesses. Use only pricing normalization of this exact identity here. - actualCandidates := []string{actualModel} - if normalized := normalizeModelNameForPricing(strings.ToLower(lastSegment(actualModel))); normalized != actualModel { - // DeepSeek's existing official price/peak policy is keyed by its bare - // model identity. A provider prefix must not bypass that policy merely - // because the global catalog can resolve the last path segment. - // Explicit group/channel pricing for the original identity still wins. - if isDeepSeekModel(normalized) && s.resolveOpenAIChannelPricing(ctx, actualModel, input.APIKey) == nil { - actualCandidates = []string{normalized, actualModel} - } else { - actualCandidates = append(actualCandidates, normalized) - } - } - out := make([]string, 0, len(actualCandidates)) - for _, candidate := range actualCandidates { - if identified, _ := s.hasIdentifiedOpenAIResponsePricing(ctx, candidate, input.APIKey); identified { - out = append(out, candidate) - } - } - return out -} - // openAILongContextBillingGate returns the per-account long-context opt-in. // The flag is an OpenAI-only account setting, so other platforms (Grok) return // nil — "no per-account gate" — and are governed by the group toggle alone. diff --git a/backend/internal/service/openai_key_routes.go b/backend/internal/service/openai_key_routes.go index 5e24a41..5671886 100644 --- a/backend/internal/service/openai_key_routes.go +++ b/backend/internal/service/openai_key_routes.go @@ -18,7 +18,7 @@ func WithOpenAIMessagesKeyRoute(ctx context.Context) context.Context { return context.WithValue(ctx, openAIMessagesKeyRouteContextKey{}, true) } -func openAIMessagesKeyRouteGroupAllowed(ctx context.Context, group *Group) bool { +func openAIMessagesKeyRouteGroupAllowed(ctx context.Context, group *Group, singleCandidate bool) bool { if ctx == nil { return true } @@ -28,11 +28,13 @@ func openAIMessagesKeyRouteGroupAllowed(ctx context.Context, group *Group) bool if group == nil { return false } - if group.Platform == PlatformMixed || group.Platform == PlatformGrok || IsCNProvider(group.Platform) { + if group.Platform == PlatformGrok || IsMultiProtocolAPIKeyProvider(group.Platform) { return true } - if platform, ok := ResolvedTargetPlatformFromContext(ctx); ok && (platform == PlatformGrok || IsCNProvider(platform)) { - return true + if group.Platform == PlatformComposite || singleCandidate { + if platform, ok := ResolvedTargetPlatformFromContext(ctx); ok && (platform == PlatformGrok || IsMultiProtocolAPIKeyProvider(platform)) { + return true + } } // Initial handler dispatch can defer this check to a later smart route. // A concrete candidate must enforce its own switch before catalog or sticky use. @@ -43,18 +45,12 @@ func openAIMessagesKeyRouteModel(ctx context.Context, group *Group, model string if enabled, _ := ctx.Value(openAIMessagesKeyRouteContextKey{}).(bool); !enabled { return model } - if group != nil && group.Platform == PlatformMixed { - return model - } normalized := NormalizeOpenAICompatRequestedModel(model) if group == nil { return normalized } if group.Platform == PlatformComposite { - if platform, ok := ResolvedTargetPlatformFromContext(ctx); ok && platform == PlatformMixed { - return model - } - if platform, ok := ResolvedTargetPlatformFromContext(ctx); ok && (platform == PlatformGrok || IsCNProvider(platform)) { + if platform, ok := ResolvedTargetPlatformFromContext(ctx); ok && (platform == PlatformGrok || IsMultiProtocolAPIKeyProvider(platform)) { return normalized } } @@ -73,7 +69,7 @@ func (s *OpenAIGatewayService) hydrateAPIKeyGroup(ctx context.Context, apiKey *A if err != nil { return nil, err } - if !openAIMessagesKeyRouteGroupAllowed(ctx, routed.Group) { + if !openAIMessagesKeyRouteGroupAllowed(ctx, routed.Group, len(apiKey.CandidateGroupIDs()) <= 1) { return nil, ErrNoAvailableAccounts } return routed, nil diff --git a/backend/internal/service/openai_live.go b/backend/internal/service/openai_live.go index 64274f2..63d1b98 100644 --- a/backend/internal/service/openai_live.go +++ b/backend/internal/service/openai_live.go @@ -17,7 +17,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" - "github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel" coderws "github.com/coder/websocket" "github.com/google/uuid" "github.com/tidwall/gjson" @@ -121,18 +120,6 @@ func ValidateLiveCallRequest(request *LiveCallRequest) error { return nil } -func checkLiveCallModelScope(ctx context.Context, request *LiveCallRequest) error { - if request == nil { - return ErrModelScopeDenied - } - models := requestmodel.FromBodyCandidates("", "application/json", request.Session) - model := strings.TrimSpace(gjson.GetBytes(request.Session, "model").String()) - if model == "" { - model = "gpt-live" - } - return CheckModelScope(ctx, append(models, model)...) -} - // CreateLiveCall 创建 Frameless 会话。调用方须在调用期间持有普通用户槽位; // 调度器持有的普通账号槽位会被同一个 Live 租约原子接替。 func (s *OpenAIGatewayService) CreateLiveCall( @@ -145,9 +132,6 @@ func (s *OpenAIGatewayService) CreateLiveCall( return nil, err } ctx = ContextWithAPIKeyRoute(ctx, identity.APIKey) - if err := checkLiveCallModelScope(ctx, request); err != nil { - return nil, err - } if s == nil || s.cfg == nil || s.cfg.RunMode != config.RunModeSimple { return nil, ErrLiveBillingNotConfigured } @@ -333,9 +317,6 @@ func sameLiveRouteGroupIDs(left, right []int64) bool { } func (s *OpenAIGatewayService) shouldFailoverLiveCreateError(account *Account, err error) bool { - if errors.Is(err, ErrModelScopeDenied) { - return false - } var upstreamErr *UpstreamFailoverError if !errors.As(err, &upstreamErr) { // 凭证读取和网络传输错误都可能只影响当前账号或代理。 @@ -354,9 +335,6 @@ func (s *OpenAIGatewayService) createUpstreamLiveCall( request *LiveCallRequest, attestation string, ) (*LiveCallCreated, error) { - if err := checkLiveCallModelScope(ctx, request); err != nil { - return nil, err - } token, _, err := s.GetAccessToken(ctx, account) if err != nil { logLiveCreateStageFailure(ctx, account.ID, "access_token", err) @@ -578,54 +556,9 @@ func (s *OpenAIGatewayService) GetLiveCallForIdentity( if record.Controller == LiveControllerClosed { return nil, ErrLiveCallNotFound } - // Reauthentication can narrow a key after this call was created. The - // persisted call identity alone does not authorize its current model. - if !identity.APIKey.AllowsModelScope(record.Model) { - return nil, ErrModelScopeDenied - } - if err := CheckModelScope(ctx, record.Model); err != nil { - return nil, err - } return record, nil } -// Inspect every occurrence instead of choosing one JSON path: duplicate and -// case-variant model fields must all remain inside the authenticated scope. -// An omitted model keeps the previously admitted Live session unchanged. -func checkLiveSidebandModelScope(ctx context.Context, payload []byte, sessionModel string) error { - if ModelScopeFromContext(ctx) == ModelScopeAll { - return nil - } - if err := CheckModelScope(ctx, sessionModel); err != nil { - return err - } - var scopeErr error - checkObject := func(object gjson.Result) { - object.ForEach(func(key, value gjson.Result) bool { - if strings.EqualFold(key.String(), "model") { - if value.Type != gjson.String { - scopeErr = ErrModelScopeDenied - } else { - scopeErr = CheckModelScope(ctx, value.String()) - } - } - return scopeErr == nil - }) - } - root := gjson.ParseBytes(payload) - checkObject(root) - if scopeErr != nil { - return scopeErr - } - root.ForEach(func(key, value gjson.Result) bool { - if value.IsObject() && (strings.EqualFold(key.String(), "session") || strings.EqualFold(key.String(), "response")) { - checkObject(value) - } - return scopeErr == nil - }) - return scopeErr -} - // ProxyLiveSideband 让认证后的客户端接管控制连接;媒体始终不经过这里。 func (s *OpenAIGatewayService) ProxyLiveSideband( ctx context.Context, @@ -635,9 +568,6 @@ func (s *OpenAIGatewayService) ProxyLiveSideband( if record == nil || downstream == nil { return ErrLiveCallNotFound } - if err := CheckModelScope(ctx, record.Model); err != nil { - return err - } store, err := s.liveStore() if err != nil { return err @@ -672,12 +602,6 @@ func (s *OpenAIGatewayService) ProxyLiveSideband( errCh <- readErr return } - if messageType == coderws.MessageText { - if scopeErr := checkLiveSidebandModelScope(proxyCtx, payload, record.Model); scopeErr != nil { - errCh <- scopeErr - return - } - } if writeErr := upstream.WriteFrame(proxyCtx, messageType, payload); writeErr != nil { errCh <- writeErr return @@ -707,11 +631,6 @@ func (s *OpenAIGatewayService) ProxyLiveSideband( runErr := s.runLiveController(proxyCtx, record, upstream, errCh) cancel() - if errors.Is(runErr, ErrModelScopeDenied) { - // Close reasons have a 123-byte protocol budget; the structured error - // includes metadata and exceeds it, preventing the close frame entirely. - _ = downstream.Close(coderws.StatusPolicyViolation, "MODEL_SCOPE_DENIED") - } _, _ = store.ReleaseLiveController(context.Background(), record.CallHash, owner) if liveSessionEnded(runErr) || !time.Now().Before(record.ExpiresAt) { s.finalizeLiveCall(record) diff --git a/backend/internal/service/openai_live_lifecycle_test.go b/backend/internal/service/openai_live_lifecycle_test.go index 23cd0bd..e40e9ad 100644 --- a/backend/internal/service/openai_live_lifecycle_test.go +++ b/backend/internal/service/openai_live_lifecycle_test.go @@ -340,156 +340,6 @@ func TestGetLiveCallForIdentityRejectsMismatchedCaller(t *testing.T) { require.Equal(t, record.AccountID, loaded.AccountID) } -func TestGetLiveCallForIdentityRejectsScopeNarrowedAfterCreate(t *testing.T) { - groupID := int64(44) - record := &LiveCallRecord{ - CallID: "call_narrowed", CallHash: hashLiveCallID("call_narrowed"), - APIKeyID: 22, UserID: 33, GroupID: groupID, Model: "gpt-live", - Controller: LiveControllerPending, - } - store := &liveTestStore{} - require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour)) - svc := &OpenAIGatewayService{cache: store} - for _, originalScope := range []string{ModelScopeAll, ModelScopeOpenAI} { - t.Run(originalScope, func(t *testing.T) { - key := &APIKey{ID: record.APIKeyID, ModelScope: originalScope} - identity := LiveCallIdentity{APIKey: key, APIKeyID: key.ID, UserID: record.UserID, GroupID: &groupID} - _, err := svc.GetLiveCallForIdentity(context.Background(), record.CallID, identity) - require.NoError(t, err) - key.ModelScope = ModelScopeCN - loaded, err := svc.GetLiveCallForIdentity(context.Background(), record.CallID, identity) - require.Nil(t, loaded) - require.ErrorIs(t, err, ErrModelScopeDenied) - }) - } - _, err := svc.GetLiveCallForIdentity(ContextWithModelScope(context.Background(), ModelScopeCN), record.CallID, - LiveCallIdentity{APIKeyID: record.APIKeyID, UserID: record.UserID, GroupID: &groupID}) - require.ErrorIs(t, err, ErrModelScopeDenied, "context scope also applies without an API-key pointer") -} - -func TestProxyLiveSidebandRejectsRecordScopeBeforeClaimOrDial(t *testing.T) { - result := make(chan error, 1) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - downstream, err := coderws.Accept(writer, request, nil) - if err != nil { - result <- err - return - } - defer func() { _ = downstream.CloseNow() }() - // A nil receiver proves denial precedes store/controller/dial access. - var svc *OpenAIGatewayService - result <- svc.ProxyLiveSideband(ContextWithModelScope(request.Context(), ModelScopeCN), - &LiveCallRecord{Model: "gpt-live"}, downstream) - })) - defer server.Close() - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - client, _, err := coderws.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) - require.NoError(t, err) - defer func() { _ = client.CloseNow() }() - select { - case err := <-result: - require.ErrorIs(t, err, ErrModelScopeDenied) - case <-ctx.Done(): - t.Fatal("record scope was not rejected before controller access") - } -} - -type liveScopeRelayStore struct { - *liveTestStore - observerDone chan struct{} -} - -func (s *liveScopeRelayStore) ClaimLiveController(ctx context.Context, hash, controller, owner string) (bool, error) { - if controller == LiveControllerObserver { - // A rejected frame hands the session back to its observer. Keep this - // test observer finite and wait for it so no background reconnect leaks. - close(s.observerDone) - return false, nil - } - return s.liveTestStore.ClaimLiveController(ctx, hash, controller, owner) -} - -func TestProxyLiveSidebandModelScopeRelay(t *testing.T) { - for _, payload := range []string{ - `{"type":"session.update","model":"qwen3"}`, - `{"type":"session.update","model":"gpt-live","MODEL":"qwen3"}`, - `{"type":"session.update","model":"qwen3","model":"gpt-live"}`, - `{"type":"session.update","session":{"model":"gpt-live","model":"qwen3"}}`, - `{"type":"session.update","session":{"model":"qwen3"},"SESSION":{"MODEL":"gpt-live"}}`, - `{"type":"response.create","RESPONSE":{"MoDeL":"qwen3"}}`, - `{"type":"response.create","response":{"model":"gpt-live"},"response":{"model":"qwen3"}}`, - `{"type":"response.create","session":{"model":"gpt-live"},"response":{"model":"qwen3"}}`, - } { - t.Run(payload, func(t *testing.T) { - account := &Account{ID: 11, Platform: PlatformOpenAI, Type: AccountTypeOAuth, - Credentials: map[string]any{"access_token": "test-access-token", "chatgpt_account_id": "acct_test"}} - record := &LiveCallRecord{ - CallID: "call_scope", CallHash: hashLiveCallID("call_scope"), AccountID: account.ID, - Model: "gpt-live", ExpiresAt: time.Now().Add(time.Minute), Controller: LiveControllerPending, - } - cipher := newLiveAttestationCipher(&config.Config{JWT: config.JWTConfig{Secret: "live-scope-test"}}) - var err error - record.AttestationCiphertext, err = cipher.Encrypt(`{"v":1,"s":0,"t":"v1.sideband"}`) - require.NoError(t, err) - store := &liveScopeRelayStore{liveTestStore: &liveTestStore{}, observerDone: make(chan struct{})} - require.NoError(t, store.SaveLiveCall(context.Background(), record, time.Hour)) - upstream := newLiveTestFrameConn() - svc := &OpenAIGatewayService{cache: store, accountRepo: &liveTestAccountRepo{account: account}, - openaiWSPassthroughDialer: &liveTestDialer{conn: upstream}, liveAttestationCipher: cipher} - result := make(chan error, 1) - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - downstream, err := coderws.Accept(writer, request, nil) - if err != nil { - result <- err - return - } - defer func() { _ = downstream.CloseNow() }() - result <- svc.ProxyLiveSideband(ContextWithModelScope(request.Context(), ModelScopeOpenAI), record, downstream) - })) - defer server.Close() - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - client, _, err := coderws.Dial(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), nil) - require.NoError(t, err) - defer func() { _ = client.CloseNow() }() - for _, frame := range []liveTestFrame{ - {messageType: coderws.MessageText, payload: []byte(`{"type":"session.update","session":{"MODEL":"gpt-live-test"}}`)}, - {messageType: coderws.MessageText, payload: []byte(`{"type":"response.create","response":{}}`)}, - {messageType: coderws.MessageBinary, payload: []byte(payload)}, - } { - require.NoError(t, client.Write(ctx, frame.messageType, frame.payload)) - select { - case actual := <-upstream.writes: - require.Equal(t, frame.messageType, actual.messageType) - require.Equal(t, frame.payload, actual.payload) - case <-ctx.Done(): - t.Fatal("allowed/inherited/binary frame was not relayed") - } - } - require.NoError(t, client.Write(ctx, coderws.MessageText, []byte(payload))) - _, _, err = client.Read(ctx) - require.Equal(t, coderws.StatusPolicyViolation, coderws.CloseStatus(err)) - select { - case err := <-result: - require.ErrorIs(t, err, ErrModelScopeDenied) - case <-ctx.Done(): - t.Fatal("cross-scope frame did not stop the relay") - } - select { - case <-store.observerDone: - case <-ctx.Done(): - t.Fatal("test observer did not exit") - } - select { - case frame := <-upstream.writes: - t.Fatalf("cross-scope text reached upstream: %s", frame.payload) - default: - } - }) - } -} - func TestProxyLiveSidebandForwardsTextAndBinary(t *testing.T) { account := &Account{ ID: 11, diff --git a/backend/internal/service/openai_messages_dispatch.go b/backend/internal/service/openai_messages_dispatch.go index a7fa690..0010301 100644 --- a/backend/internal/service/openai_messages_dispatch.go +++ b/backend/internal/service/openai_messages_dispatch.go @@ -70,7 +70,7 @@ func (g *Group) IsGrokMessagesDispatchGroup() bool { } func (g *Group) ResolveMessagesDispatchModel(requestedModel string) string { - if g == nil || g.Platform == PlatformMixed { + if g == nil { return "" } requestedModel = strings.TrimSpace(requestedModel) @@ -92,10 +92,10 @@ func (g *Group) ResolveMessagesDispatchModel(requestedModel string) string { return xai.DefaultTextModel } - // 国产供应商分组:调度级模型映射不适用(其配置被 sanitize 置空,且下方的 + // 多协议 API Key 供应商分组:调度级模型映射不适用(其配置被 sanitize 置空,且下方的 // gpt-5.x 默认值是 openai 专属,发给 CN 上游必错)。模型改写完全交给账号级 // model_mapping;anthropic 协议上游本身接受 claude-* 模型名。 - if IsCNProvider(g.Platform) { + if IsMultiProtocolAPIKeyProvider(g.Platform) { return "" } diff --git a/backend/internal/service/openai_plugin_transport.go b/backend/internal/service/openai_plugin_transport.go index 6e19dd8..bfb664b 100644 --- a/backend/internal/service/openai_plugin_transport.go +++ b/backend/internal/service/openai_plugin_transport.go @@ -9,9 +9,6 @@ func (s *OpenAIGatewayService) SetPluginManager(manager *PluginManager) { // doOpenAIUpstream 只在 OpenAI OAuth 能力绑定已启用时把真实请求交给插件。 // 插件返回标准 http.Response,响应解析、错误映射、SSE 和计费仍由现有核心链处理。 func (s *OpenAIGatewayService) doOpenAIUpstream(request *http.Request, proxyURL string, account *Account) (*http.Response, error) { - if err := CheckUpstreamModelScope(request); err != nil { - return nil, err - } logGrokUpstreamHTTPDump(request, proxyURL, account) if s.pluginManager != nil { response, handled, err := s.pluginManager.RoundTripOpenAIOAuth(request.Context(), request, proxyURL, account) diff --git a/backend/internal/service/openai_subscription_long_context_test.go b/backend/internal/service/openai_subscription_long_context_test.go index 5f5d26e..ce9a235 100644 --- a/backend/internal/service/openai_subscription_long_context_test.go +++ b/backend/internal/service/openai_subscription_long_context_test.go @@ -206,44 +206,6 @@ func TestAccountStatsSubscriptionPolicyPreservesExplicitPricing(t *testing.T) { } } -func TestOpenAIGatewayServiceRecordUsage_GPT61MixedVendorRateAndOverride(t *testing.T) { - for _, accountType := range []string{AccountTypeOAuth, AccountTypeAPIKey} { - for _, override := range []*float64{nil, testPtrFloat64(0), testPtrFloat64(0.25)} { - t.Run(fmt.Sprintf("%s/%v", accountType, override), func(t *testing.T) { - logs := &openAIRecordUsageLogRepoStub{inserted: true} - users := &openAIRecordUsageUserRepoStub{} - svc := newOpenAIRecordUsageServiceForTest(logs, users, &openAIRecordUsageSubRepoStub{}, nil) - svc.userGroupRateResolver = newUserGroupRateResolver(&userGroupRateRepoHotpathStub{rate: override}, nil, time.Minute, nil, "test") - key := openAIRecordUsageAPIKeyWithGroup(svc, 1, true) - key.GroupID = i64p(1) - key.Group.Platform = PlatformMixed - key.Group.RateMultiplier = 9 // aggregate rate must never be used - key.Group.MixedProviderRateMultipliers = map[string]float64{"openai": 0.15} - err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ - APIKey: key, User: &User{ID: 2}, - Account: &Account{ID: 3, Platform: PlatformOpenAI, Type: accountType}, - ChannelUsageFields: ChannelUsageFields{OriginalModel: "unknown-public-alias", BillingModelSource: BillingModelSourceRequested}, - Result: &OpenAIForwardResult{RequestID: "mixed-gpt61", Model: "unknown-public-alias", UpstreamModel: "gpt-6.1-sol", - Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000}}, - }) - require.NoError(t, err) - rate := 0.15 - if override != nil { - rate = *override - } - want := 0.62 - if accountType == AccountTypeAPIKey { - want = 1.23 - } - require.NotNil(t, logs.lastLog) - require.InDelta(t, want, logs.lastLog.TotalCost, 1e-12) - require.InDelta(t, want*rate, logs.lastLog.ActualCost, 1e-12) - require.Equal(t, rate, logs.lastLog.RateMultiplier) - }) - } - } -} - func TestOpenAIGatewayServiceRecordUsage_GPT61SolLongContextBoundary(t *testing.T) { for _, totalInput := range []int{271999, 272000, 272001} { for _, accountType := range []string{AccountTypeOAuth, AccountTypeAPIKey} { diff --git a/backend/internal/service/openai_upstream_transport_error.go b/backend/internal/service/openai_upstream_transport_error.go index 28b0cd3..a5813ee 100644 --- a/backend/internal/service/openai_upstream_transport_error.go +++ b/backend/internal/service/openai_upstream_transport_error.go @@ -106,9 +106,6 @@ func classifyUpstreamTransportError(err error) upstreamTransportErrorClass { // // passthrough tags the Ops error event for the OpenAI passthrough forward path. func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, passthrough bool) error { - if errors.Is(err, ErrModelScopeDenied) { - return err - } safeErr := sanitizeUpstreamErrorMessage(err.Error()) setOpsUpstreamError(c, 0, safeErr, "") appendOpsUpstreamError(c, OpsUpstreamErrorEvent{ diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index b069467..2305b8f 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -387,9 +387,6 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } } upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(requestModel)) - if err := CheckModelScope(ctx, originalModel, requestModel, upstreamModel); err != nil { - return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, err.Error(), err) - } if modelMissing || upstreamModel != originalModel { next, setErr := applyPayloadMutation(normalized, "model", upstreamModel) if setErr != nil { diff --git a/backend/internal/service/openai_ws_forwarder_v2.go b/backend/internal/service/openai_ws_forwarder_v2.go index dbfa53f..21bddb2 100644 --- a/backend/internal/service/openai_ws_forwarder_v2.go +++ b/backend/internal/service/openai_ws_forwarder_v2.go @@ -36,9 +36,6 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( lastFailureReason string, agentTaskRecoveryTried *bool, ) (*OpenAIForwardResult, error) { - if err := CheckModelScope(ctx, originalModel, mappedModel); err != nil { - return nil, err - } if s == nil || account == nil { return nil, wrapOpenAIWSFallback("invalid_state", errors.New("service or account is nil")) } diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 64dc4f2..c5e617d 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -14,7 +14,6 @@ import ( "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" - "github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel" openaiwsv2 "github.com/Wei-Shaw/sub2api/internal/service/openai_ws_v2" coderws "github.com/coder/websocket" "github.com/gin-gonic/gin" @@ -781,14 +780,6 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } } capturedSessionModel := openAIWSPassthroughPolicyModelForFrame(account, firstClientMessage) - firstScopeCandidates := requestmodel.FromBodyCandidates("", "application/json", firstClientMessage) - firstScopeCandidates = append(firstScopeCandidates, requestmodel.FromBodyCandidates("/v1/live", "application/json", firstClientMessage)...) - if err := CheckModelScope(ctx, firstScopeCandidates...); err != nil { - return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, err.Error(), err) - } - if err := CheckModelScope(ctx, initialRequestModel, capturedSessionModel); err != nil { - return NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, err.Error(), err) - } if capturedSessionModel != "" && capturedSessionModel != strings.TrimSpace(gjson.GetBytes(firstClientMessage, "model").String()) { firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel) } @@ -1071,11 +1062,6 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } } if isResponseCreate || eventType == "session.update" { - candidates := requestmodel.FromBodyCandidates("", "application/json", payload) - candidates = append(candidates, requestmodel.FromBodyCandidates("/v1/live", "application/json", payload)...) - if err := CheckModelScope(ctx, candidates...); err != nil { - return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, err.Error(), err) - } accountScopedPayload, accountScoped, scopeErr := applyCodexAccountIdentityClientMetadataRaw(payload, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c)) if scopeErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket identity metadata", scopeErr) @@ -1161,9 +1147,6 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if model == "" { model = loadCapturedSessionModel() } - if err := CheckModelScope(ctx, model); err != nil { - return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, err.Error(), err) - } if isResponseCreate && model != "" && model != strings.TrimSpace(gjson.GetBytes(payload, "model").String()) { payload = s.ReplaceModelInBody(payload, model) } diff --git a/backend/internal/service/provider_profile.go b/backend/internal/service/provider_profile.go index c88d269..d461087 100644 --- a/backend/internal/service/provider_profile.go +++ b/backend/internal/service/provider_profile.go @@ -208,9 +208,6 @@ func (a *Account) providerProfile() *ProviderProfile { if a == nil { return nil } - if native := mixedOfficialProviderView(a); native != nil { - return LookupProviderProfile(native.Platform) - } return LookupProviderProfile(a.Platform) } diff --git a/backend/internal/service/provider_registry_safety_test.go b/backend/internal/service/provider_registry_safety_test.go index 5256067..13e52eb 100644 --- a/backend/internal/service/provider_registry_safety_test.go +++ b/backend/internal/service/provider_registry_safety_test.go @@ -9,36 +9,10 @@ import ( "testing" "time" - "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) -func TestProviderCatalogPreservesMixedIdentity(t *testing.T) { - spec, ok := domain.LookupPlatform(PlatformMixed) - require.True(t, ok) - require.Equal(t, domain.PlatformGatewayOpenAI, spec.Gateway) - require.False(t, spec.CNProvider) - require.Empty(t, spec.LiteLLMProvider) - require.Nil(t, LookupProviderProfile(PlatformMixed)) - require.False(t, IsMultiProtocolAPIKeyProvider(PlatformMixed)) - for _, entry := range BuildPlatformCatalog().Platforms { - if entry.ID == PlatformMixed { - require.Nil(t, entry.MultiProtocol) - } - require.NotEqual(t, "opencode_go", entry.ID) - if entry.ID == PlatformTypeSafe { - require.Equal(t, domain.PlatformGatewaySystemOne, entry.Gateway) - require.Nil(t, entry.MultiProtocol) - } - } - account := &Account{Platform: PlatformMixed, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test", "base_url": DefaultDeepseekBaseURL}} - require.Equal(t, PlatformDeepseek, account.providerProfile().Platform) - require.Equal(t, PlatformMixed, account.Platform) - account.Credentials["base_url"] = "https://relay.example/v1" - require.Nil(t, account.providerProfile()) -} - func TestProviderAccountProbesRejectUntrustedOrigins(t *testing.T) { for _, platform := range []string{PlatformCline, PlatformCommandCode} { host := "api.cline.bot" diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 2b52d6b..ce109b9 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -1053,7 +1053,7 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst // 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据, // 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/ // 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。 - if account.Platform == PlatformOpenAI || account.IsMixed() || account.IsMultiProtocolAPIKey() { + if account.Platform == PlatformOpenAI || IsCNProvider(account.Platform) { return s.handleOpenAI403(ctx, account, upstreamMsg, responseBody) } // 非 Antigravity 平台:保持原有行为 diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index ce32a4d..99268e4 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -998,8 +998,6 @@ func cloneSchedulerGroup(group *Group) *Group { cp.FallbackGroupIDOnInvalidRequest = cloneGroupValuePointer(group.FallbackGroupIDOnInvalidRequest) cp.ModelRouting = cloneGroupModelRouting(group.ModelRouting) cp.VideoModelPrices = cloneGroupVideoModelPrices(group.VideoModelPrices) - cp.MixedProviderRateMultipliers = CloneMixedProviderRateMultipliers(group.MixedProviderRateMultipliers) - cp.SupportedModelVendors = append([]string(nil), group.SupportedModelVendors...) cp.SupportedModelScopes = append([]string(nil), group.SupportedModelScopes...) if group.ModelPricing != nil { cp.ModelPricing = make([]ChannelModelPricing, len(group.ModelPricing)) @@ -1324,7 +1322,7 @@ func (s *SchedulerSnapshotService) handleBulkAccountEvent(ctx context.Context, p } accountGroupIDs := s.normalizeGroupIDs(account.GroupIDs) switch account.Platform { - case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformMixed, PlatformCommandCode, PlatformCline, PlatformTypeSafe: + case PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformCline, PlatformCommandCode, PlatformTypeSafe: addPlatformGroups(account.Platform, accountGroupIDs) case PlatformAntigravity: // 批量更新可能刚关闭 mixed_scheduling,仍需清理两个兼容平台的旧快照。 diff --git a/backend/internal/service/smart_route_core_test.go b/backend/internal/service/smart_route_core_test.go index 365fc24..adb57ae 100644 --- a/backend/internal/service/smart_route_core_test.go +++ b/backend/internal/service/smart_route_core_test.go @@ -330,8 +330,8 @@ func TestSmartRouteCoreMessagesCandidateDispatchPermission(t *testing.T) { {"messages_zhipu_exempt", PlatformZhipu, "", true, false, true}, {"messages_deepseek_exempt", PlatformDeepseek, "", true, false, true}, {"messages_minimax_exempt", PlatformMiniMax, "", true, false, true}, - {"messages_openai_resolved_grok_exempt", PlatformOpenAI, PlatformGrok, true, false, true}, - {"messages_openai_resolved_kimi_exempt", PlatformOpenAI, PlatformKimi, true, false, true}, + {"messages_openai_cannot_borrow_grok_permission", PlatformOpenAI, PlatformGrok, true, false, false}, + {"messages_openai_cannot_borrow_kimi_permission", PlatformOpenAI, PlatformKimi, true, false, false}, {"messages_composite_openai_disabled", PlatformComposite, PlatformOpenAI, true, false, false}, {"messages_composite_grok_exempt", PlatformComposite, PlatformGrok, true, false, true}, {"messages_composite_deepseek_exempt", PlatformComposite, PlatformDeepseek, true, false, true}, diff --git a/backend/internal/service/systemone_billing_test.go b/backend/internal/service/systemone_billing_test.go index d67a1d4..45bbc04 100644 --- a/backend/internal/service/systemone_billing_test.go +++ b/backend/internal/service/systemone_billing_test.go @@ -61,14 +61,10 @@ func TestSystemOnePreauthorizationAndUsageActualInputOnly(t *testing.T) { require.NotContains(t, f.recorder.snapshot(), "repo_begin_refund") } -func TestSystemOneSchedulingAndScopes(t *testing.T) { - for _, platform := range []string{PlatformOpenAI, PlatformMixed, PlatformAnthropic, PlatformGemini, PlatformCommandCode, PlatformCline} { +func TestSystemOneSchedulingIsolation(t *testing.T) { + for _, platform := range []string{PlatformOpenAI, PlatformAnthropic, PlatformGemini, PlatformCommandCode, PlatformCline} { require.False(t, groupUsableForRequest(&Group{Platform: platform}, PlatformTypeSafe, "jev-latest", map[string]struct{}{PlatformOpenAI: {}})) } require.True(t, groupUsableForRequest(&Group{Platform: PlatformTypeSafe}, PlatformTypeSafe, "jev-latest")) require.False(t, groupUsableForRequest(&Group{Platform: PlatformTypeSafe}, PlatformOpenAI, "gpt-6")) - for _, scope := range []string{ModelScopeOpenAI, ModelScopeAnthropic, ModelScopeCN} { - require.Error(t, CheckModelScope(ContextWithModelScope(context.Background(), scope), "jev-latest", "jev-latest")) - } - require.NoError(t, CheckModelScope(ContextWithModelScope(context.Background(), ModelScopeOther), "jev-latest", "jev-latest")) } diff --git a/backend/internal/service/upstream_affinity_failure.go b/backend/internal/service/upstream_affinity_failure.go index 623f3b6..917d702 100644 --- a/backend/internal/service/upstream_affinity_failure.go +++ b/backend/internal/service/upstream_affinity_failure.go @@ -51,7 +51,7 @@ func invalidateUpstreamAffinity(ctx context.Context, cache GatewayCache, groupID } // Local policy and a canceled client are not an upstream failure. var policyErr *BetaBlockedError - if errors.Is(err, ErrModelScopeDenied) || errors.As(err, &policyErr) || (errors.Is(err, context.Canceled) && ctx.Err() != nil) { + if errors.As(err, &policyErr) || (errors.Is(err, context.Canceled) && ctx.Err() != nil) { return } if state, ok := ctx.Value(upstreamAttemptContextKey{}).(*upstreamAttempts); ok { diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go index b1043fa..3d612e5 100644 --- a/backend/internal/service/upstream_billing_probe.go +++ b/backend/internal/service/upstream_billing_probe.go @@ -627,11 +627,7 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "missing_api_key", 0) } baseURL := account.GetCredential("base_url") - if account.IsMixed() { - // Match Mixed forwarding: strip a full wire endpoint while preserving - // the configured origin and any vendor/version prefix. - baseURL = normalizeMixedBaseURL(baseURL) - } else if account.RoutesProtocolByInbound() && account.IsAdaptiveAPIProtocol() { + if account.RoutesProtocolByInbound() && account.IsAdaptiveAPIProtocol() { baseURL = account.GetCNProtocolBaseURL(APIProtocolChatCompletions) } if account.Platform == PlatformOpenAI { diff --git a/backend/internal/service/upstream_billing_probe_multiplatform_test.go b/backend/internal/service/upstream_billing_probe_multiplatform_test.go index 10d9595..d76e868 100644 --- a/backend/internal/service/upstream_billing_probe_multiplatform_test.go +++ b/backend/internal/service/upstream_billing_probe_multiplatform_test.go @@ -16,7 +16,7 @@ import ( func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, PlatformGrok, PlatformAnthropic, PlatformGemini, PlatformAntigravity, - PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformMixed, + PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, } { require.True(t, IsUpstreamBillingProbeIdentity(platform, AccountTypeAPIKey), platform) require.True(t, isUpstreamBillingProbeAccount(&Account{Platform: platform, Type: AccountTypeAPIKey}), platform) @@ -24,71 +24,11 @@ func TestUpstreamBillingProbeIdentityCoversAllAPIKeyPlatforms(t *testing.T) { require.False(t, IsUpstreamBillingProbeIdentity(PlatformOpenAI, AccountTypeOAuth)) require.False(t, IsUpstreamBillingProbeIdentity(PlatformGrok, AccountTypeOAuth)) require.False(t, IsUpstreamBillingProbeIdentity(PlatformAnthropic, AccountTypeBedrock)) - for _, accountType := range []string{AccountTypeOAuth, AccountTypeBedrock, AccountTypeUpstream, "", "future-type"} { - require.False(t, IsUpstreamBillingProbeIdentity(PlatformMixed, accountType), accountType) - } require.False(t, IsUpstreamBillingProbeIdentity("", AccountTypeAPIKey)) require.False(t, IsUpstreamBillingProbeIdentity("future-platform", AccountTypeAPIKey)) require.False(t, isUpstreamBillingProbeAccount(nil)) } -func TestUpstreamBillingProbeMixedUsesConfiguredBillingURLWithoutRedirects(t *testing.T) { - account := &Account{ - ID: 153, Platform: PlatformMixed, Type: AccountTypeAPIKey, Status: StatusActive, - Credentials: map[string]any{"api_key": "nvapi-test", "base_url": "https://nvidia.example/v1"}, - Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true, UpstreamBillingRateSyncEnabledExtraKey: false}, - } - repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} - upstream := &httpUpstreamRecorder{resp: &http.Response{ - StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: upstreamBillingProbeValidBody(), - }} - svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}) - snapshot, err := svc.ProbeAccount(context.Background(), account.ID) - require.NoError(t, err) - require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) - require.Len(t, upstream.requests, 1) - require.Equal(t, http.MethodGet, upstream.lastReq.Method) - require.Equal(t, "https://nvidia.example/v1/sub2api/billing", upstream.lastReq.URL.String()) - require.Equal(t, "Bearer nvapi-test", upstream.lastReq.Header.Get("Authorization")) - require.True(t, HTTPUpstreamRedirectsDisabled(upstream.lastReq.Context())) - require.Equal(t, HTTPUpstreamProfileDefault, HTTPUpstreamProfileFromContext(upstream.lastReq.Context())) - require.Equal(t, false, repo.accounts[account.ID].Extra[UpstreamBillingRateSyncEnabledExtraKey]) -} - -func TestUpstreamBillingProbeMixedFullEndpointBasesUseConfiguredRoot(t *testing.T) { - for _, tc := range []struct{ base, want string }{ - {"https://relay.example/v1/chat/completions", "https://relay.example/v1/sub2api/billing"}, - {"https://relay.example/v1/responses", "https://relay.example/v1/sub2api/billing"}, - {"https://relay.example/v1/messages", "https://relay.example/v1/sub2api/billing"}, - {"https://relay.example/gateway/v2/chat/completions", "https://relay.example/gateway/v2/sub2api/billing"}, - {"https://relay.example/gateway/v2/responses/", "https://relay.example/gateway/v2/sub2api/billing"}, - {"https://relay.example/gateway/v2/messages", "https://relay.example/gateway/v2/sub2api/billing"}, - {"https://relay.example/gateway/chat/completions", "https://relay.example/gateway/v1/sub2api/billing"}, - } { - t.Run(tc.base, func(t *testing.T) { - account := &Account{ - ID: 153, Platform: PlatformMixed, Type: AccountTypeAPIKey, Status: StatusActive, - Credentials: map[string]any{"api_key": "nvapi-test", "base_url": tc.base}, - Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true, UpstreamBillingRateSyncEnabledExtraKey: false}, - } - repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}} - upstream := &httpUpstreamRecorder{resp: &http.Response{ - StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, - Body: upstreamBillingProbeValidBody(), - }} - snapshot, err := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{}).ProbeAccount(context.Background(), account.ID) - require.NoError(t, err) - require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status) - require.Len(t, upstream.requests, 1) - require.Equal(t, tc.want, upstream.lastReq.URL.String()) - require.True(t, HTTPUpstreamRedirectsDisabled(upstream.lastReq.Context())) - require.Equal(t, tc.base, account.GetCredential("base_url")) - require.Equal(t, false, account.Extra[UpstreamBillingRateSyncEnabledExtraKey]) - }) - } -} - func TestBuildUpstreamBillingRateSnapshotItemsPreservesAllAPIKeyPlatforms(t *testing.T) { makeAccount := func(id int64, platform, accountType string, snapshot any) Account { return Account{ @@ -207,9 +147,6 @@ func TestUpstreamBillingProbeOfficialAPIBaseURLIsUnsupportedWithoutRequest(t *te baseURL string }{ {PlatformAnthropic, "https://api.anthropic.com"}, - {PlatformMixed, "https://api.anthropic.com"}, - {PlatformMixed, "https://api.deepseek.com/v1"}, - {PlatformMixed, ""}, {PlatformAnthropic, "https://api.anthropic.com:443"}, {PlatformAnthropic, "https://api.anthropic.com./"}, {PlatformAnthropic, "HTTPS://API.ANTHROPIC.COM/"}, diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 455242d..a58b733 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -1218,7 +1218,7 @@ func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, acc return s.buildAntigravityAPIKeyModelsRequest(ctx, account) case account.IsGrok(): return s.buildGrokUpstreamModelsRequest(ctx, account) - case account.IsOpenAI() || account.IsMixed() || account.IsMultiProtocolAPIKey() || account.IsGeminiOpenAIProtocol(): + case account.IsOpenAI() || account.IsMultiProtocolAPIKey() || account.IsGeminiOpenAIProtocol(): // 国产 OpenAI 兼容供应商和 Gemini 自定义协议账号复用 OpenAI /v1/models 探测。 return s.buildOpenAIUpstreamModelsRequest(ctx, account) case account.IsGemini(): @@ -1435,9 +1435,6 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex // 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.IsMixed() { - ctx = WithHTTPUpstreamRedirectsDisabled(ctx) - } if account.Type != AccountTypeAPIKey { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", account.Type), nil, @@ -1452,7 +1449,7 @@ func buildOpenAIAPIKeyModelsRequest(ctx context.Context, account *Account, valid // 列表同步需使用 OpenAI 格式 base(供应商 × 模式默认)。 baseURL := account.GetOpenAIFormatBaseURL() if strings.TrimSpace(baseURL) == "" { - if account.IsMixed() || account.IsMultiProtocolAPIKey() || account.IsGeminiOpenAIProtocol() { + if account.IsMultiProtocolAPIKey() || account.IsGeminiOpenAIProtocol() { return nil, newUpstreamModelSyncConfigError("Account requires an explicit OpenAI-compatible model catalog endpoint", nil) } baseURL = "https://api.openai.com" diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index 981b3b6..9207af8 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -72,26 +72,6 @@ func TestBuildV1ModelsURL(t *testing.T) { require.Equal(t, "https://gateway.example.com/antigravity/v1/models", buildV1ModelsURL("https://gateway.example.com/antigravity/")) } -func TestDomesticUpstreamModelSyncUsesConfiguredURLAndPreservesCatalog(t *testing.T) { - for _, protocol := range []string{"", APIProtocolChatCompletions, APIProtocolResponses, APIProtocolAnthropic} { - upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"moonshotai/kimi-k2.6"},{"id":"xiaomi/mimo-v2"},{"id":"Agents-A1"},{"id":"Atria-Dawn-Preview"}]}`))}} - svc := &AccountTestService{cfg: upstreamModelSyncTestConfig(), httpUpstream: upstream} - account := &Account{Platform: PlatformMixed, Type: AccountTypeAPIKey, Credentials: map[string]any{ - "base_url": "https://domestic.example/prefix/v1", "api_key": "domestic-key", "api_protocol": protocol, - "header_override_enabled": true, "header_overrides": map[string]any{"X-Custom-Tenant": "tenant-value"}, - }} - models, err := svc.FetchUpstreamSupportedModels(context.Background(), account) - require.NoError(t, err) - require.ElementsMatch(t, []string{"moonshotai/kimi-k2.6", "xiaomi/mimo-v2", "Agents-A1", "Atria-Dawn-Preview"}, models) - require.Equal(t, http.MethodGet, upstream.lastReq.Method) - require.Equal(t, "https://domestic.example/prefix/v1/models", upstream.lastReq.URL.String()) - require.Equal(t, "Bearer domestic-key", upstream.lastReq.Header.Get("Authorization")) - require.Equal(t, []string{"tenant-value"}, headerValuesEqualFold(upstream.lastReq.Header, "X-Custom-Tenant")) - require.True(t, HTTPUpstreamRedirectsDisabled(upstream.lastReq.Context())) - } -} - func TestBuildOpenAIModelsURL(t *testing.T) { t.Parallel() diff --git a/backend/internal/service/user_group_rate.go b/backend/internal/service/user_group_rate.go index 3375ad5..70fdecf 100644 --- a/backend/internal/service/user_group_rate.go +++ b/backend/internal/service/user_group_rate.go @@ -10,8 +10,8 @@ import ( const maxUserGroupRateMultiplier = 999999.9999 func validateUserGroupRateMultiplier(rate float64) error { - if math.IsNaN(rate) || math.IsInf(rate, 0) || rate < 0 || rate > maxUserGroupRateMultiplier { - return fmt.Errorf("rate_multiplier must be finite and between 0 and %.4f", maxUserGroupRateMultiplier) + if math.IsNaN(rate) || math.IsInf(rate, 0) || rate <= 0 || rate > maxUserGroupRateMultiplier { + return fmt.Errorf("rate_multiplier must be finite and greater than 0 and at most %.4f", maxUserGroupRateMultiplier) } return nil } diff --git a/docs/release-notes/v2.1.18.md b/docs/release-notes/v2.1.18.md new file mode 100644 index 0000000..1b965eb --- /dev/null +++ b/docs/release-notes/v2.1.18.md @@ -0,0 +1,12 @@ +# Sub2API 2.1.18 + +撤回本项目自建的 Mixed 聚合方案,恢复原有独立平台入口和调用链路。 + +- Kimi、智谱 GLM、DeepSeek、MiniMax 恢复独立的账号、分组和 Key 选择入口;保留 OpenAI、Anthropic、Gemini、Grok、Antigravity、Command Code、Cline、TypeSafe 等原生能力。 +- 删除 Mixed 专属协议探测与回退、流式处理、按模型厂商倍率及 Key 四类模型访问范围;调用继续使用各平台已有实现和原生兼容转换。 +- 保留普通模型映射、Composite 路由、分组模型白名单、官方价格库、分组及用户专属倍率、固定金额小铺充值和上游更新检查。 +- 保留原生调用的工具历史、最终请求余额预授权、连接测试和模型同步回归;恢复 Cline、Command Code 的正式 Messages 调度和原始模型名,不增加另一套聚合路由。 + +升级注意:历史迁移文件及兼容列不改删。已有 Mixed 账号/分组及非 `all` 范围的 Key 不会被启动程序静默扩大权限或猜测迁移,需维护者备份后显式转为原生配置,否则将明确拒绝。普通原生账号和分组无需迁移,用户余额及历史用量不换算。 + +不引入 LinuxDo 邀请传递、多邀请码扩展,以及此前撤回的自建账务对账、日志或冻结价格机制。外部质量检测、内容审计的默认关闭和授权边界保持不变。 diff --git a/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts b/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts index ca6712b..275b733 100644 --- a/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts +++ b/frontend/src/api/__tests__/settings.authSourceDefaults.spec.ts @@ -297,7 +297,12 @@ describe("sanitizePlatformQuotasMap", () => { }); describe("platform quota catalog coverage", () => { - it.each(['mixed', 'kimi', 'zhipu', 'deepseek', 'minimax', 'command_code', 'cline'])( + it("drops retired Mixed platform quotas", () => { + for (const transform of [normalizePlatformQuotasMap, sanitizePlatformQuotasMap]) { + expect(transform({ mixed: { daily: 0, weekly: 10, monthly: 50 } })).not.toHaveProperty("mixed"); + } + }); + it.each(['kimi', 'zhipu', 'deepseek', 'minimax', 'command_code', 'cline'])( "保留 %s 平台的显式零值和限额", (platform) => { const limits = { daily: 0, weekly: 10, monthly: 50 }; diff --git a/frontend/src/api/keys.ts b/frontend/src/api/keys.ts index 8a6ea91..2000831 100644 --- a/frontend/src/api/keys.ts +++ b/frontend/src/api/keys.ts @@ -69,11 +69,9 @@ export async function create( rateLimitData?: { rate_limit_5h?: number; rate_limit_1d?: number; rate_limit_7d?: number }, groupIds?: number[], anthropicCacheTtlMode?: 'inherit' | 'off' | '1h' | '5m', - options?: { idempotencyKey?: string }, - modelScope?: CreateApiKeyRequest['model_scope'] + options?: { idempotencyKey?: string } ): Promise { const payload: CreateApiKeyRequest = { name } - if (modelScope !== undefined) payload.model_scope = modelScope if (groupId !== undefined) { payload.group_id = groupId } diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index b638d69..a156c71 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -6,7 +6,6 @@ import { apiClient } from './client' import type { UserPricingInterval, UserSupportedModelPricing } from './channels' -import type { MixedModelVendor } from '@/utils/mixed-provider-rates' /** 官方参考价(USD per token,与计费目录同源;字段缺失 = 目录未覆盖)。 */ export interface PlazaOfficialPricing { @@ -47,12 +46,6 @@ export interface PlazaTimePricing { export interface PlazaModel { name: string platform: string - /** Mixed 模型的厂商绝对倍率,不含用户专属或高峰倍率。 */ - rate_multiplier?: number - /** Mixed 模型计费身份对应的厂商。 */ - model_vendor?: MixedModelVendor - /** 实际计费身份;同一公开别名的不同路由候选分别展示。 */ - billing_model?: string /** 实收口径的展示定价:档位可提供绝对单价或相对基础价倍率;均为标准时段价。 */ pricing: UserSupportedModelPricing | null official_pricing: PlazaOfficialPricing | null diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 8e2d932..80e319c 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -154,22 +154,19 @@ {{ spec.display_name }} -
@@ -1331,7 +1328,6 @@
@@ -1555,7 +1551,7 @@
-
+
@@ -3091,7 +3087,7 @@
@@ -3936,7 +3932,6 @@ const withUpstreamRequestIdHeader = | undefin const baseUrlHint = computed(() => { if (form.platform === 'typesafe') return t('admin.accounts.typesafeHint') - if (form.platform === 'mixed') return t('admin.accounts.mixedHint') if (isGeminiProtocolAccount.value) return t('admin.accounts.gemini.accountType.customProtocolHint') if (form.platform === 'openai') return t('admin.accounts.openai.baseUrlHint') if (form.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint') @@ -3945,7 +3940,6 @@ const baseUrlHint = computed(() => { }) const apiKeyHint = computed(() => { - if (form.platform === 'mixed') return '' if (form.type === 'cloudflare') return '' if (isGeminiProtocolAccount.value) return t('admin.accounts.openai.apiKeyHint') if (form.platform === 'openai') return t('admin.accounts.openai.apiKeyHint') @@ -3962,8 +3956,6 @@ const apiKeyBaseUrlPlaceholder = computed(() => { switch (form.platform) { case 'typesafe': return 'https://api.typesafe.ai' - case 'mixed': - return 'https://your-upstream/v1' case 'openai': return 'https://api.openai.com' case 'gemini': @@ -3992,8 +3984,7 @@ const apiKeyValuePlaceholder = computed(() => { case 'minimax': return 'sk-...' case 'typesafe': - case 'mixed': - return 'your-api-key' + return 'ts-...' default: return 'sk-ant-...' } @@ -4099,7 +4090,7 @@ const adaptiveBaseUrls = ref>({ }) const geminiUseCustomProtocol = ref(false) const isCNPlatform = computed(() => isCNProviderPlatform(form.platform)) -const genericProviderPlatforms = computed(() => listPlatforms().filter(spec => (spec.multi_protocol && !spec.cn_provider) || spec.gateway === 'systemone')) +const genericProviderPlatforms = computed(() => listPlatforms().filter(spec => spec.multi_protocol)) const providerRules = ref([]) const isGeminiProtocolAccount = computed( () => form.platform === 'gemini' && accountCategory.value === 'apikey' && geminiUseCustomProtocol.value @@ -4739,7 +4730,7 @@ watch( watch( [accountCategory, addMethod, antigravityAccountType, () => form.platform], ([category, method, agType]) => { - if (form.platform === 'mixed' || isMultiProtocolApiKeyPlatform(form.platform)) { + if (isMultiProtocolApiKeyPlatform(form.platform) || form.platform === 'typesafe') { form.type = 'apikey' return } @@ -4840,13 +4831,6 @@ watch( poolModeEnabled.value = false upstreamBillingAutoProbeEnabled.value = false } - if (newPlatform === 'mixed') { - accountCategory.value = 'apikey' - form.type = 'apikey' - apiKeyBaseUrl.value = '' - poolModeEnabled.value = false - upstreamBillingAutoProbeEnabled.value = false - } // Reset Bedrock fields when switching platforms bedrockAccessKeyId.value = '' bedrockSecretAccessKey.value = '' @@ -5486,15 +5470,6 @@ const buildOpenAICodexImportExtra = (): Record | undefined => { } const buildAnthropicExtra = (base?: Record): Record | undefined => { - if (form.platform === 'mixed' && accountCategory.value === 'apikey') { - const extra: Record = { ...(base || {}) } - if (anthropicAPIKeyAuthScheme.value === 'authorization_bearer') { - extra.anthropic_apikey_auth_scheme = 'authorization_bearer' - } else { - delete extra.anthropic_apikey_auth_scheme - } - return Object.keys(extra).length > 0 ? extra : undefined - } if (form.platform !== 'anthropic' || accountCategory.value !== 'apikey') { return base } @@ -5809,11 +5784,7 @@ const handleSubmit = async () => { } // Determine default base URL based on platform - if (form.platform === 'mixed' && !apiKeyBaseUrl.value.trim()) { - appStore.showError(t('admin.accounts.mixedBaseUrlRequired')) - return - } - const defaultBaseUrl = form.platform === 'mixed' ? '' : form.platform === 'openai' + const defaultBaseUrl = form.platform === 'openai' ? 'https://api.openai.com' : form.platform === 'gemini' ? 'https://generativelanguage.googleapis.com' @@ -5886,7 +5857,7 @@ const handleSubmit = async () => { } // Add pool mode if enabled - if (form.platform !== 'mixed' && poolModeEnabled.value) { + if (poolModeEnabled.value) { credentials.pool_mode = true credentials.pool_mode_retry_count = normalizePoolModeRetryCount(poolModeRetryCount.value) const parsedRetryStatusCodes = parsePoolModeRetryStatusCodes(poolModeRetryStatusCodesInput.value) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index da3c398..957f0cf 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -58,12 +58,9 @@ -
+
@@ -1887,7 +1882,7 @@
@@ -3048,9 +3043,8 @@ const handleOllamaCloudUsageUpdated = (state: OllamaCloudUsageState) => { // Platform-specific hint for Base URL const baseUrlHint = computed(() => { + if (props.account?.platform === 'typesafe') return t('admin.accounts.typesafeHint') if (!props.account) return t('admin.accounts.baseUrlHint') - if (props.account.platform === 'typesafe') return t('admin.accounts.typesafeHint') - if (props.account.platform === 'mixed') return t('admin.accounts.mixedBaseUrlRequired') if (props.account.platform === 'openai') return t('admin.accounts.openai.baseUrlHint') if (props.account.platform === 'gemini') return t('admin.accounts.gemini.baseUrlHint') if (props.account.platform === 'grok') return '' @@ -3711,7 +3705,6 @@ const tempUnschedPresets = computed(() => [ const defaultBaseUrl = computed(() => { if (props.account && isMultiProtocolApiKeyPlatform(props.account.platform)) return defaultCNBaseUrl(props.account.platform, editAccountMode.value, editApiProtocol.value) if (props.account?.platform === 'typesafe') return 'https://api.typesafe.ai' - if (props.account?.platform === 'mixed') return '' if (props.account?.platform === 'openai') return 'https://api.openai.com' if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com' if (props.account?.platform === 'grok') return 'https://api.x.ai/v1' @@ -3970,11 +3963,6 @@ const syncFormFromAccount = (newAccount: Account | null) => { openAICompactModelMappings.value = Object.entries(compactMappings).map(([from, to]) => ({ from, to })) } } - if (newAccount.platform === 'mixed' && newAccount.type === 'apikey') { - anthropicAPIKeyAuthScheme.value = extra?.anthropic_apikey_auth_scheme === 'authorization_bearer' - ? 'authorization_bearer' - : 'x_api_key' - } if (newAccount.platform === 'anthropic' && newAccount.type === 'apikey') { anthropicPassthroughEnabled.value = extra?.anthropic_passthrough === true anthropicAPIKeyAuthScheme.value = extra?.anthropic_apikey_auth_scheme === 'authorization_bearer' @@ -4160,11 +4148,8 @@ const syncFormFromAccount = (newAccount: Account | null) => { } } const platformDefaultUrl = - newAccount.platform === 'typesafe' - ? 'https://api.typesafe.ai' - : newAccount.platform === 'mixed' - ? '' - : newAccount.platform === 'openai' + newAccount.platform === 'typesafe' ? 'https://api.typesafe.ai' : + newAccount.platform === 'openai' ? 'https://api.openai.com' : newAccount.platform === 'gemini' ? 'https://generativelanguage.googleapis.com' @@ -4861,10 +4846,6 @@ const handleSubmit = async () => { if (props.account.type === 'apikey' || props.account.type === 'cloudflare') { const currentCredentials = (props.account.credentials as Record) || {} const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value - if (props.account.platform === 'mixed' && !newBaseUrl) { - appStore.showError(t('admin.accounts.mixedBaseUrlRequired')) - return - } const shouldApplyModelMapping = !(props.account.platform === 'openai' && openaiPassthroughEnabled.value) // Always update credentials for apikey type to handle model mapping changes @@ -5397,17 +5378,6 @@ const handleSubmit = async () => { updatePayload.extra = newExtra } - if (props.account.platform === 'mixed' && props.account.type === 'apikey') { - const currentExtra = (updatePayload.extra as Record) || (props.account.extra as Record) || {} - const newExtra: Record = { ...currentExtra } - if (anthropicAPIKeyAuthScheme.value === 'authorization_bearer') { - newExtra.anthropic_apikey_auth_scheme = 'authorization_bearer' - } else { - delete newExtra.anthropic_apikey_auth_scheme - } - updatePayload.extra = newExtra - } - // For OpenAI OAuth/SetupToken/API Key accounts, handle passthrough mode in extra if (props.account.platform === 'openai' && (props.account.type === 'oauth' || props.account.type === 'setup-token' || props.account.type === 'apikey')) { const currentExtra = (props.account.extra as Record) || {} diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 1d409af..0513fdd 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -69,8 +69,6 @@ vi.mock('vue-i18n', async () => { }) import CreateAccountModal from '../CreateAccountModal.vue' -import enAccountMessages from '@/i18n/locales/en/admin/accounts' -import zhAccountMessages from '@/i18n/locales/zh/admin/accounts' const BaseDialogStub = defineComponent({ name: 'BaseDialog', @@ -256,6 +254,18 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(wrapper.find('[data-testid="vertex-api-key-input"]').exists()).toBe(false) wrapper.unmount() }) + it.each(['kimi', 'zhipu', 'deepseek', 'minimax'])('creates an independent %s native account', async (platform) => { + createAccountMock.mockResolvedValue({ id: 42, platform, type: 'apikey' }) + const wrapper = mountModal() + await wrapper.get(`[data-testid="platform-button-${platform}"]`).trigger('click') + await flushPromises() + await wrapper.get('form#create-account-form input[type="text"]').setValue('native provider') + await wrapper.get('form#create-account-form input[type="password"]').setValue('native-provider-key') + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + expect(createAccountMock.mock.calls[0]?.[0]).toMatchObject({ platform, type: 'apikey', credentials: { api_key: 'native-provider-key' } }) + wrapper.unmount() + }) it.each(['command_code', 'cline'])('creates catalog provider %s with its native endpoints and protocol rules', async (platform) => { createAccountMock.mockResolvedValue({ id: 42, platform, type: 'apikey' }) const wrapper = mountModal() @@ -299,72 +309,8 @@ describe('CreateAccountModal OpenAI long-context billing', () => { afterEach(() => vi.useRealTimers()) - it('labels the generic creation entry Mixed Group with its own decorative logo', () => { - for (const messages of [enAccountMessages, zhAccountMessages]) { - expect(messages.accounts.domesticCreateLabel).toBe('Mixed Group') - expect(messages.accounts.platforms.mixed).toBe('Mixed Group') - } - const wrapper = mountModal() - const button = wrapper.get('[data-testid="platform-mixed"]') - expect(button.text()).toContain('admin.accounts.domesticCreateLabel') - expect(button.attributes('type')).toBe('button') - expect(button.findAllComponents({ name: 'PlatformIcon' }).map((icon) => icon.props('platform'))) - .toEqual(['mixed']) - expect(button.findAllComponents({ name: 'PlatformIcon' }).every((icon) => icon.attributes('aria-hidden') === 'true')).toBe(true) - wrapper.unmount() - }) - it('creates Domestic with explicit credentials and persists previewed model metadata without billing scans', async () => { - const wrapper = mountModal() - await wrapper.get('[data-testid="platform-mixed"]').trigger('click') - await flushPromises() - expect(wrapper.findComponent(OAuthAuthorizationFlowStub).exists()).toBe(false) - expect(wrapper.find('[data-testid="upstream-billing-auto-probe"]').exists()).toBe(false) - const base = wrapper.get('input[placeholder="https://your-upstream/v1"]') - expect(base.element.value).toBe('') - expect(base.attributes('required')).toBeDefined() - await wrapper.get('form#create-account-form input[type="text"]').setValue('mixed upstream') - await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') - await wrapper.get('form#create-account-form').trigger('submit.prevent') - await flushPromises() - expect(createAccountMock).not.toHaveBeenCalled() - await base.setValue('https://relay.example/v1') - await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click') - await wrapper.get('form#create-account-form').trigger('submit.prevent') - await flushPromises() - expect(createAccountMock).toHaveBeenCalledTimes(1) - const payload = createAccountMock.mock.calls[0]?.[0] - expect(payload.platform).toBe('mixed') - expect(payload.type).toBe('apikey') - expect(payload.credentials.base_url).toBe('https://relay.example/v1') - expect(payload.credentials.api_key).toBe('test-api-key') - expect(payload.credentials.api_protocol).toBeUndefined() - expect(payload.credentials.pool_mode).toBeUndefined() - expect(payload.upstream_billing_probe_enabled).toBe(false) - expect(probeUpstreamBillingMock).not.toHaveBeenCalled() - expect(syncUpstreamModelsMock).toHaveBeenCalledTimes(1) - expect(syncUpstreamModelsMock).toHaveBeenCalledWith(42) - wrapper.unmount() - }) - it('supports native Messages bearer auth for Mixed without enabling passthrough or search', async () => { - const wrapper = mountModal() - await wrapper.get('[data-testid="platform-mixed"]').trigger('click') - await flushPromises() - await wrapper.get('form#create-account-form input[type="text"]').setValue('mixed bearer') - await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') - await wrapper.get('input[placeholder="https://your-upstream/v1"]').setValue('https://relay.example/v1') - await wrapper.get('[data-testid="anthropic-api-key-auth-scheme"]').setValue('authorization_bearer') - await wrapper.get('form#create-account-form').trigger('submit.prevent') - await flushPromises() - expect(createAccountMock).toHaveBeenCalledTimes(1) - const payload = createAccountMock.mock.calls[0]?.[0] - expect(payload.extra.anthropic_apikey_auth_scheme).toBe('authorization_bearer') - expect(payload.extra.anthropic_passthrough).toBeUndefined() - expect(payload.extra.web_search_emulation).toBeUndefined() - expect(syncUpstreamModelsMock).not.toHaveBeenCalled() - wrapper.unmount() - }) it('accepts zero priority and rejects negative or fractional input', async () => { const wrapper = mountModal() @@ -642,20 +588,6 @@ describe('CreateAccountModal OpenAI long-context billing', () => { expect(payload?.credentials?.tier_id).toBeUndefined() }) - it('consolidates domestic account creation under the existing mixed platform', async () => { - const wrapper = mountModal() - const labels = wrapper.findAll('button').map(button => button.text()) - for (const provider of ['Kimi', 'Zhipu GLM', 'DeepSeek', 'MiniMax']) { - expect(labels).not.toContain(provider) - } - await wrapper.get('[data-testid="platform-mixed"]').trigger('click') - await wrapper.get('input[placeholder="https://your-upstream/v1"]').setValue('https://mimo.example/v1') - await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-domestic') - expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('syncCredentials')).toMatchObject({ - platform: 'mixed', type: 'apikey', base_url: 'https://mimo.example/v1', api_key: 'sk-domestic' - }) - wrapper.unmount() - }) it('previews Gemini custom OpenAI-compatible upstreams as chat completions', async () => { const wrapper = mountModal([{ id: 9, platform: 'gemini' }]) diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index c52ced3..7aeacf5 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -451,70 +451,8 @@ describe('EditAccountModal', () => { afterEach(() => vi.useRealTimers()) - it('edits mixed model mappings with custom URL guidance while preserving identity and billing opt-in', async () => { - const account = buildAccount() - account.platform = 'mixed' - account.credentials = { api_key: 'nvapi-test', base_url: 'https://nvidia.example/v1', model_mapping: { alias: 'old-model' }, preserved_credential: 'keep' } - account.extra = { upstream_billing_probe_enabled: true, upstream_billing_rate_sync_enabled: false, preserved_setting: true } - updateAccountMock.mockReset().mockResolvedValue(account) - checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) - const wrapper = mountModal(account) - const base = wrapper.get('input[placeholder="https://your-upstream/v1"]') - expect(base.attributes('required')).toBeDefined() - expect(wrapper.text()).toContain('admin.accounts.mixedBaseUrlRequired') - expect(wrapper.get('input[type="password"]').attributes('placeholder')).toBe('your-api-key') - const mapping = wrapper.findAll('input').find((input) => (input.element as HTMLInputElement).value === 'old-model')! - await mapping.setValue('new-model') - await wrapper.get('form#edit-account-form').trigger('submit.prevent') - await flushPromises() - const payload = updateAccountMock.mock.calls[0]?.[1] - expect(payload.credentials).toMatchObject({ ...account.credentials, model_mapping: { alias: 'new-model' } }) - expect(payload.upstream_billing_probe_enabled).toBe(true) - expect(payload.upstream_billing_rate_sync_enabled).toBe(false) - expect(payload.extra).toMatchObject({ preserved_setting: true }) - expect(payload.extra).not.toHaveProperty('upstream_billing_probe_enabled') - expect(payload.extra).not.toHaveProperty('upstream_billing_rate_sync_enabled') - expect(payload).not.toHaveProperty('type') - expect(payload).not.toHaveProperty('platform') - wrapper.unmount() - }) - it.each([undefined, ''])('does not infer an Anthropic URL for a mixed account without a URL (%s)', async (baseUrl) => { - const account = buildAccount() - account.platform = 'mixed' - account.credentials = { api_key: 'nvapi-test', base_url: baseUrl } - updateAccountMock.mockReset().mockResolvedValue(account) - checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) - const wrapper = mountModal(account) - expect(wrapper.get('input[placeholder="https://your-upstream/v1"]').element.value).toBe('') - await wrapper.get('form#edit-account-form').trigger('submit.prevent') - await flushPromises() - expect(updateAccountMock).not.toHaveBeenCalled() - wrapper.unmount() - }) - it('preserves Mixed Messages auth on edit and allows restoring x-api-key', async () => { - const account = buildAccount() - account.platform = 'mixed' - account.extra = { anthropic_apikey_auth_scheme: 'authorization_bearer', preserved_setting: true } - account.credentials = { api_key: 'sk-preserved', base_url: 'https://relay.example/v1', model_mapping: { alias: 'qwen3.8-27b' } } - updateAccountMock.mockReset().mockResolvedValue(account) - checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) - const wrapper = mountModal(account) - expect(wrapper.get('[data-testid="anthropic-api-key-auth-scheme"]').element.value).toBe('authorization_bearer') - await wrapper.get('form#edit-account-form').trigger('submit.prevent') - await flushPromises() - expect(updateAccountMock.mock.calls[0]?.[1]?.extra).toMatchObject({ anthropic_apikey_auth_scheme: 'authorization_bearer', preserved_setting: true }) - expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject(account.credentials) - await wrapper.get('[data-testid="anthropic-api-key-auth-scheme"]').setValue('x_api_key') - await wrapper.get('form#edit-account-form').trigger('submit.prevent') - await flushPromises() - const updated = updateAccountMock.mock.calls.at(-1)?.[1] - expect(updated.extra.anthropic_apikey_auth_scheme).toBeUndefined() - expect(updated.extra.preserved_setting).toBe(true) - expect(updated.extra.anthropic_passthrough).toBeUndefined() - wrapper.unmount() - }) it('saves zero priority without the previous minimum-one restriction', async () => { const account = buildAccount() diff --git a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts index a88d7f8..1de1d9c 100644 --- a/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts +++ b/frontend/src/components/account/__tests__/ModelWhitelistSelector.spec.ts @@ -89,21 +89,6 @@ describe('ModelWhitelistSelector', () => { expect(showError).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsError: Vertex keys cannot list models') wrapper.unmount() }) - it.each([true, false])('syncs domestic models using saved account=%s without rewriting upstream IDs', async (saved) => { - const models = ['deepseek-ai/deepseek-v4-flash', 'xiaomi/mimo-v2', 'Agents-A1'] - syncUpstreamModels.mockResolvedValue({ models }) - syncUpstreamModelsPreview.mockResolvedValue({ models }) - const credentials = { platform: 'mixed', type: 'apikey', base_url: 'https://relay.example/v1', api_key: 'sk-example' } - const wrapper = mountSelector({ platform: 'mixed', ...(saved ? { accountId: 1271 } : { syncCredentials: credentials }) }) - const button = wrapper.findAll('button').find(button => button.text() === 'admin.accounts.syncUpstreamModels') - expect(button).toBeDefined() - await button!.trigger('click') - await flushPromises() - expect(wrapper.emitted('update:modelValue')).toEqual([[models]]) - if (saved) expect(syncUpstreamModels).toHaveBeenCalledWith(1271) - else expect(syncUpstreamModelsPreview).toHaveBeenCalledWith(credentials) - wrapper.unmount() - }) beforeEach(() => { copyToClipboard.mockClear() diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts index de207ce..381d494 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.platformCatalog.spec.ts @@ -3,12 +3,6 @@ import { cnBalanceCellVisible, cnQuotaCellVisible, providerNativeProtocols, prov import { BUILTIN_PLATFORM_CATALOG } from '@/constants/platformCatalog' describe('provider catalog capabilities', () => { - it('keeps Mixed outside provider profiles and excludes unsupported platforms', () => { - expect(BUILTIN_PLATFORM_CATALOG.platforms.find(spec => spec.id === 'mixed')?.multi_protocol).toBeUndefined() - expect(BUILTIN_PLATFORM_CATALOG.platforms.some(spec => spec.id === 'opencode_go')).toBe(false) - expect(providerNativeProtocols('cline')).toEqual(['chat_completions']) - expect(providerNativeProtocols('command_code')).toEqual(['chat_completions', 'anthropic', 'responses']) - }) it('registers TypeSafe only as a native System One gateway', () => { const platform = BUILTIN_PLATFORM_CATALOG.platforms.find(spec => spec.id === 'typesafe') diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index 9f75dbb..2e6f5f5 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -43,7 +43,11 @@ export interface HeaderOverrideRow { /** 请求头覆写资格(与后端 IsHeaderOverrideEligible 保持一致) */ export function isHeaderOverrideCapable(platform: string, type: string): boolean { - if (platform === 'anthropic' || platform === 'openai' || platform === 'mixed' || isMultiProtocolApiKeyPlatform(platform)) { + if ( + platform === 'anthropic' || + platform === 'openai' || + isMultiProtocolApiKeyPlatform(platform) + ) { return type === 'apikey' || (platform === 'openai' && type === 'cloudflare') } if (platform === 'grok') { diff --git a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue index 4dc5a18..c96a290 100644 --- a/frontend/src/components/admin/group/GroupRateMultipliersModal.vue +++ b/frontend/src/components/admin/group/GroupRateMultipliersModal.vue @@ -9,8 +9,8 @@ | {{ group.name }} - | - + | + {{ t('admin.groups.columns.rateMultiplier') }}: {{ group.rate_multiplier }}x
@@ -55,7 +55,7 @@ v-model.number="newRate" type="number" step="0.001" - :min="group.platform === 'mixed' ? 0 : 0.001" + min="0.001" max="999999.9999" autocomplete="off" class="hide-spinner input w-full" @@ -82,7 +82,8 @@ v-model.number="batchFactor" type="number" step="0.1" - min="0" + min="0.001" + max="999999.9999" autocomplete="off" class="hide-spinner w-20 rounded border border-gray-200 bg-white px-2 py-1 text-center text-sm transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500" placeholder="0.5" @@ -169,11 +170,11 @@ @@ -283,11 +284,10 @@ const newRate = ref(null) const currentPage = ref(1) const pageSize = ref(10) const batchFactor = ref(null) -const canEditDraft = computed(() => props.show && !!props.group && entriesLoaded.value && !loading.value) +const canEditDraft = computed(() => props.show && !!props.group && entriesLoaded.value && !loading.value) const validRate = (rate: number | null | undefined): rate is number => - typeof rate === 'number' && Number.isFinite(rate) && rate >= 0 && rate <= 999999.9999 && - (rate !== 0 || props.group?.platform === 'mixed') + typeof rate === 'number' && Number.isFinite(rate) && rate > 0 && rate <= 999999.9999 let searchTimeout: ReturnType let loadVersion = 0 diff --git a/frontend/src/components/admin/group/MixedProviderRateFields.vue b/frontend/src/components/admin/group/MixedProviderRateFields.vue deleted file mode 100644 index d3c2c0a..0000000 --- a/frontend/src/components/admin/group/MixedProviderRateFields.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/frontend/src/components/admin/group/__tests__/GroupRateMultipliersModal.requests.spec.ts b/frontend/src/components/admin/group/__tests__/GroupRateMultipliersModal.requests.spec.ts index d8ec5df..baf4f10 100644 --- a/frontend/src/components/admin/group/__tests__/GroupRateMultipliersModal.requests.spec.ts +++ b/frontend/src/components/admin/group/__tests__/GroupRateMultipliersModal.requests.spec.ts @@ -36,26 +36,6 @@ async function openGroup() { } describe('group rate multiplier request ownership', () => { - it('adds and saves an explicit Mixed zero override without showing a hidden total rate', async () => { - mocks.getGroupRateMultipliers.mockResolvedValue([]) - const wrapper = await openGroup() - await wrapper.setProps({ group: { ...group(1), platform: 'mixed', rate_multiplier: 9 } }) - await flushPromises() - const view = wrapper.vm as unknown as { - newRate: number - selectUser: (user: AdminUser) => void - handleAddLocal: () => void - } - view.selectUser({ id: 3, email: 'user3@example.com', status: 'active' } as AdminUser) - view.newRate = 0 - await flushPromises() - expect(wrapper.findAll('button').find(button => button.text() === 'common.add')!.attributes('disabled')).toBeUndefined() - view.handleAddLocal() - await flushPromises() - expect(wrapper.text()).not.toContain('9x') - await wrapper.findAll('button').find(button => button.text() === 'common.save')!.trigger('click') - expect(mocks.batchSetGroupRateMultipliers).toHaveBeenCalledWith(1, [{ user_id: 3, rate_multiplier: 0 }]) - }) it.each([0, -1, 1000000, Number.NaN, Infinity])('blocks invalid ordinary rate %s before saving', async (rate) => { mocks.getGroupRateMultipliers.mockResolvedValue(entries(1)) diff --git a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue index 3ef7e08..7939f79 100644 --- a/frontend/src/components/admin/user/UserAllowedGroupsModal.vue +++ b/frontend/src/components/admin/user/UserAllowedGroupsModal.vue @@ -67,10 +67,9 @@ {{ config.platform }} • - + {{ t('admin.users.defaultRate') }}: {{ config.defaultRate }}x - {{ t('common.mixedProviderRates') }}
@@ -80,11 +79,11 @@
@@ -147,10 +146,9 @@ {{ config.platform }} • - + {{ t('admin.users.defaultRate') }}: {{ config.defaultRate }}x - {{ t('common.mixedProviderRates') }}
@@ -160,11 +158,11 @@
@@ -321,8 +319,7 @@ const updateCustomRate = (groupId: number, value: string) => { const handleSave = async () => { if (!props.user) return if (groupConfigs.value.some((c) => c.customRate !== null && ( - !Number.isFinite(c.customRate) || c.customRate < 0 || c.customRate > 999999.9999 || - (c.customRate === 0 && c.platform !== 'mixed') + !Number.isFinite(c.customRate) || c.customRate <= 0 || c.customRate > 999999.9999 ))) { appStore.showError(t('admin.groups.failedToSave')) return diff --git a/frontend/src/components/admin/user/__tests__/UserAllowedGroupsModal.rates.spec.ts b/frontend/src/components/admin/user/__tests__/UserAllowedGroupsModal.rates.spec.ts index c4e174f..cfcb64e 100644 --- a/frontend/src/components/admin/user/__tests__/UserAllowedGroupsModal.rates.spec.ts +++ b/frontend/src/components/admin/user/__tests__/UserAllowedGroupsModal.rates.spec.ts @@ -11,7 +11,7 @@ enableAutoUnmount(afterEach) beforeEach(() => vi.resetAllMocks()) async function open(platform: GroupPlatform, customRate?: number) { - const group = { id: 10, name: 'Group', platform, rate_multiplier: platform === 'mixed' ? 9 : 1, status: 'active', subscription_type: 'standard', is_exclusive: false } as Group + const group = { id: 10, name: 'Group', platform, rate_multiplier: 1, status: 'active', subscription_type: 'standard', is_exclusive: false } as Group mocks.list.mockResolvedValue({ items: [group], total: 1 }) mocks.update.mockResolvedValue({}) const user = { id: 42, email: 'user@example.com', group_rates: customRate === undefined ? {} : { 10: customRate } } as AdminUser @@ -27,18 +27,12 @@ async function open(platform: GroupPlatform, customRate?: number) { return wrapper } -describe('user-specific Mixed rate editing', () => { - it('does not advertise a hidden Mixed total as its default or placeholder', async () => { - const wrapper = await open('mixed') - expect(wrapper.text()).toContain('common.mixedProviderRates') - expect(wrapper.text()).not.toContain('9x') - expect(wrapper.get('input[type="number"]').attributes('placeholder')).toBe('') - }) - it.each([['mixed', '0', 0], ['mixed', '0.5', 0.5], ['openai', '0.5', 0.5]] as const)( +describe('user-specific native rate editing', () => { + it.each([['kimi', '0.5', 0.5], ['openai', '0.5', 0.5]] as const)( 'saves %s override %s', async (platform, input, expected) => { const wrapper = await open(platform) const field = wrapper.get('input[type="number"]') - expect(field.attributes('min')).toBe(platform === 'mixed' ? '0' : '0.001') + expect(field.attributes('min')).toBe('0.001') await field.setValue(input) await wrapper.findAll('button').find(b => b.text() === 'common.save')!.trigger('click') await flushPromises() @@ -46,7 +40,7 @@ describe('user-specific Mixed rate editing', () => { } ) - it.each([['openai', 0], ['mixed', -1], ['mixed', 1000000], ['mixed', Number.NaN], ['mixed', Infinity], ['mixed', -Infinity]] as const)( + it.each([['openai', 0], ['openai', -1], ['openai', 1000000], ['openai', Number.NaN], ['openai', Infinity], ['openai', -Infinity]] as const)( 'rejects invalid %s override %s before sending', async (platform, value) => { const wrapper = await open(platform, value) await wrapper.findAll('button').find(b => b.text() === 'common.save')!.trigger('click') @@ -55,8 +49,8 @@ describe('user-specific Mixed rate editing', () => { } ) - it('clears a saved Mixed zero with null', async () => { - const wrapper = await open('mixed', 0) + it('clears a saved native zero with null', async () => { + const wrapper = await open('openai', 0) await wrapper.get('input[type="number"]').setValue('') await wrapper.findAll('button').find(b => b.text() === 'common.save')!.trigger('click') await flushPromises() diff --git a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts index e381bf1..032747d 100644 --- a/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts +++ b/frontend/src/components/admin/user/__tests__/UserPlatformQuotaModal.spec.ts @@ -142,13 +142,13 @@ describe('UserPlatformQuotaModal', () => { const [uid, payload] = apiMocks.updatePlatformQuotas.mock.calls[0] expect(uid).toBe(99) expect(payload.map((item: { platform: string }) => item.platform)).toEqual(listPlatformIds()) - expect(payload).toContainEqual({ platform: 'mixed', daily_limit_usd: null, weekly_limit_usd: null, monthly_limit_usd: null }) + expect(payload).not.toContainEqual(expect.objectContaining({ platform: 'mixed' })) const openai = payload.find((p: any) => p.platform === 'openai') expect(openai.weekly_limit_usd).toBe(20) }) - it('保留 Mixed 和原生供应商平台的限额并保存', async () => { - const platforms = ['mixed', 'kimi', 'zhipu', 'deepseek', 'minimax', 'command_code', 'cline'] + it('保留原生供应商平台的限额并保存', async () => { + const platforms = ['kimi', 'zhipu', 'deepseek', 'minimax', 'command_code', 'cline'] const limits = platforms.map((platform, index) => ({ platform, daily_limit_usd: 0, weekly_limit_usd: index + 1, monthly_limit_usd: 50, })) diff --git a/frontend/src/components/common/GroupBadge.vue b/frontend/src/components/common/GroupBadge.vue index ee50413..79df5e2 100644 --- a/frontend/src/components/common/GroupBadge.vue +++ b/frontend/src/components/common/GroupBadge.vue @@ -2,8 +2,7 @@ @@ -11,8 +10,7 @@ {{ name }} - - +