diff --git a/README.md b/README.md index 62ab427214..20c0668b82 100644 --- a/README.md +++ b/README.md @@ -816,7 +816,7 @@ xAI quota is passive. Sub2API does not invent subscription quota values; it reco `401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling. -New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts require positive paid-entitlement evidence from the xAI billing probe; Free, forbidden, missing, malformed, and inconclusive billing observations are excluded from new media generation. Unobserved OAuth accounts are probed before the first media request is forwarded, and imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`. +New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts with explicit Free or forbidden billing evidence are excluded from new media generation. Missing or malformed observations are probed before dispatch; a successful but incomplete billing response is treated as `billing_inconclusive` and remains eligible for backwards compatibility, because an unknown billing schema is not proof that the account lacks media entitlement. Operators can quarantine a known-bad account with `extra.grok_media_eligible=false` or force-enable a verified account with `true`. Imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`. Administrators can override automatic media eligibility through the account create/update API by setting `extra.grok_media_eligible` to `false` (exclude) or `true` (force eligible). On update, set it to `null` to remove the override and return to automatic probe-based behavior; omitting the field preserves the current override. A weekly allowance period alone is not treated as a paid tier signal. Successful image responses must contain at least one actual image output; empty HTTP `200` responses trigger account failover instead of being counted and returned as successful generations. diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index c21b25f9d0..e9977ede0f 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -210,7 +210,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { dataManagementService := service.NewDataManagementService() dataManagementHandler := admin.NewDataManagementHandler(dataManagementService) backupObjectStoreFactory := repository.NewS3BackupStoreFactory() - dbDumper := repository.NewPgDumper(configConfig) + dbDumper := repository.NewPgDumper(configConfig, db) backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db) imageStorageFactory := repository.ProvideImageStorageFactory() imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig) diff --git a/backend/internal/domain/reasoning_effort.go b/backend/internal/domain/reasoning_effort.go index 88f4263fc2..64ecf8ad62 100644 --- a/backend/internal/domain/reasoning_effort.go +++ b/backend/internal/domain/reasoning_effort.go @@ -7,7 +7,8 @@ const ( ) // ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort -// value to another before the group ceiling is applied. +// value to another before the group ceiling is applied. To may also be "deny" +// to reject the request when the matching source value is present. // // Model and MatchType optionally scope the rewrite to a request model: // exact matches the full model id, prefix/suffix match a model-id affix. diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index ffa2408bcd..ae01687bdb 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -81,15 +81,19 @@ type userSupportedModelPricing struct { // userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。 type userPricingIntervalDTO struct { - MinTokens int `json:"min_tokens"` - MaxTokens *int `json:"max_tokens"` - TierLabel string `json:"tier_label,omitempty"` - InputPrice *float64 `json:"input_price"` - OutputPrice *float64 `json:"output_price"` - CacheWritePrice *float64 `json:"cache_write_price"` - CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` - CacheReadPrice *float64 `json:"cache_read_price"` - PerRequestPrice *float64 `json:"per_request_price"` + MinTokens int `json:"min_tokens"` + MaxTokens *int `json:"max_tokens"` + TierLabel string `json:"tier_label,omitempty"` + InputPrice *float64 `json:"input_price"` + OutputPrice *float64 `json:"output_price"` + CacheWritePrice *float64 `json:"cache_write_price"` + CacheWrite1hPrice *float64 `json:"cache_write_1h_price"` + CacheReadPrice *float64 `json:"cache_read_price"` + InputMultiplier *float64 `json:"input_multiplier"` + OutputMultiplier *float64 `json:"output_multiplier"` + CacheWriteMultiplier *float64 `json:"cache_write_multiplier"` + CacheReadMultiplier *float64 `json:"cache_read_multiplier"` + PerRequestPrice *float64 `json:"per_request_price"` } // userSupportedModel 用户可见的支持模型条目。 @@ -295,15 +299,19 @@ func toUserPricingIntervals(src []service.PricingInterval) []userPricingInterval intervals := make([]userPricingIntervalDTO, 0, len(src)) for _, iv := range src { intervals = append(intervals, userPricingIntervalDTO{ - MinTokens: iv.MinTokens, - MaxTokens: iv.MaxTokens, - TierLabel: iv.TierLabel, - InputPrice: iv.InputPrice, - OutputPrice: iv.OutputPrice, - CacheWritePrice: iv.CacheWritePrice, - CacheWrite1hPrice: iv.CacheWrite1hPrice, - CacheReadPrice: iv.CacheReadPrice, - PerRequestPrice: iv.PerRequestPrice, + MinTokens: iv.MinTokens, + MaxTokens: iv.MaxTokens, + TierLabel: iv.TierLabel, + InputPrice: iv.InputPrice, + OutputPrice: iv.OutputPrice, + CacheWritePrice: iv.CacheWritePrice, + CacheWrite1hPrice: iv.CacheWrite1hPrice, + CacheReadPrice: iv.CacheReadPrice, + InputMultiplier: iv.InputMultiplier, + OutputMultiplier: iv.OutputMultiplier, + CacheWriteMultiplier: iv.CacheWriteMultiplier, + CacheReadMultiplier: iv.CacheReadMultiplier, + PerRequestPrice: iv.PerRequestPrice, }) } return intervals diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index 24d7d1cb45..325f10133b 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -113,10 +113,18 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { } // pricing interval 白名单:不应暴露 id / sort_order。 + inputMultiplier := 2.0 + outputMultiplier := 1.5 + cacheWriteMultiplier := 2.0 + cacheReadMultiplier := 2.0 pricing := toUserPricing(&service.ChannelModelPricing{ BillingMode: service.BillingModeToken, Intervals: []service.PricingInterval{ - {ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3}, + { + ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3, + InputMultiplier: &inputMultiplier, OutputMultiplier: &outputMultiplier, + CacheWriteMultiplier: &cacheWriteMultiplier, CacheReadMultiplier: &cacheReadMultiplier, + }, }, }) require.NotNil(t, pricing) @@ -129,6 +137,14 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { _, exists := ivDecoded[key] require.Falsef(t, exists, "user pricing interval must not expose %q", key) } + for key, want := range map[string]float64{ + "input_multiplier": inputMultiplier, "output_multiplier": outputMultiplier, + "cache_write_multiplier": cacheWriteMultiplier, "cache_read_multiplier": cacheReadMultiplier, + } { + got, exists := ivDecoded[key] + require.Truef(t, exists, "user pricing interval must expose %q", key) + require.InDelta(t, want, got.(float64), 1e-12) + } } func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) { diff --git a/backend/internal/handler/composite_platform_test.go b/backend/internal/handler/composite_platform_test.go index d9a642ce06..af5726e217 100644 --- a/backend/internal/handler/composite_platform_test.go +++ b/backend/internal/handler/composite_platform_test.go @@ -152,6 +152,23 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) { var overLimit *service.ReasoningEffortOverLimitError require.ErrorAs(t, err, &overLimit) + mappingDenyGroup := *group + mappingDenyGroup.MaxReasoningEffort = "" + mappingDenyGroup.ReasoningEffortMappings = []service.ReasoningEffortMapping{ + {From: "max", To: service.ReasoningEffortMappingDeny}, + } + mappingDenyAPIKey := &service.APIKey{Group: &mappingDenyGroup} + mappingDenyCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) + mappingDenyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil) + mappingDenyCtx.Request = mappingDenyCtx.Request.WithContext(service.WithResolvedTargetPlatform(mappingDenyCtx.Request.Context(), service.PlatformOpenAI)) + _, changed, err = applyOpenAIReasoningEffortPolicyForRequest(mappingDenyCtx, mappingDenyAPIKey, body) + require.Error(t, err) + require.False(t, changed) + var mappingDenied *service.ReasoningEffortMappingDeniedError + require.ErrorAs(t, err, &mappingDenied) + require.Equal(t, "max", mappingDenied.Requested) + require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy") + grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder()) grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil) grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok)) diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 686231bf32..a073f0f336 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1303,6 +1303,10 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID * } func writeModelsList(c *gin.Context, platform string, modelIDs []string) { + if platform == service.PlatformOpenAI { + writeOpenAIModelsList(c, modelIDs) + return + } if platform == service.PlatformGrok { writeGrokModelsList(c, modelIDs) return diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 4b4d0fdb6f..4facbfe790 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -181,7 +182,7 @@ func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) { } } -// Scenario: Composite manifests aggregate only administrator-configured models. +// Composite manifests include defaults from unmapped accounts and explicit mappings. func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) { gin.SetMode(gin.TestMode) const groupID int64 = 120 @@ -225,7 +226,105 @@ func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T require.Equal(t, http.StatusOK, rec.Code) var got codexModelsResponseForTest require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) - require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models)) + want := service.FilterCodexModelIDsForGroup(openai.DefaultModelIDs(), nil) + require.ElementsMatch(t, append(want, "grok-4.6"), codexModelSlugsForTest(got.Models)) +} + +func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 28 + const sparkModel = "gpt-5.3-codex-spark" + const alias = "team-coder" + parentID := int64(1) + accounts := []service.Account{ + {ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, + { + ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + ParentAccountID: &parentID, QuotaDimension: "spark", + Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}}, + }, + { + ID: 3, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey, + Credentials: map[string]any{"model_mapping": map[string]any{alias: "gpt-5.6-sol"}}, + }, + } + tests := []struct { + name string + accounts []service.Account + config service.GroupModelAllowlist + want []string + }{ + { + name: "unmapped parent and Spark shadow retain defaults and aliases", + accounts: accounts, + want: append(openai.DefaultModelIDs(), alias), + }, + { + name: "unmapped API key account also contributes defaults", + accounts: append([]service.Account{{ID: 4, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}}, accounts[1:]...), + want: append(openai.DefaultModelIDs(), alias), + }, + { + name: "unmapped accounts alone retain default response shape", + accounts: accounts[:1], + want: openai.DefaultModelIDs(), + }, + { + name: "custom list can select defaults and aliases", + accounts: accounts, + config: service.GroupModelAllowlist{Enabled: true, Models: []string{alias, "gpt-5.6-sol", sparkModel, "unknown-model"}}, + want: []string{alias, "gpt-5.6-sol", sparkModel}, + }, + { + name: "unavailable custom selection remains empty", + accounts: accounts, + config: service.GroupModelAllowlist{Enabled: true, Models: []string{"unknown-model"}}, + want: []string{}, + }, + { + name: "mapped accounts alone do not gain defaults", + accounts: accounts[1:], + want: []string{sparkModel, alias}, + }, + { + name: "unmapped accounts from another platform do not add defaults", + accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...), + want: []string{sparkModel, alias}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{groupID: tt.accounts}, + }) + for range 2 { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, ModelAllowlist: tt.config}, + }) + h.Models(c) + require.Equal(t, http.StatusOK, rec.Code) + var got gatewayModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, "list", got.Object) + require.ElementsMatch(t, tt.want, modelIDsForTest(got.Data)) + for _, model := range got.Data { + require.Equal(t, "model", model.Object, model.ID) + require.Positive(t, model.Created, model.ID) + require.Equal(t, "openai", model.OwnedBy, model.ID) + require.Empty(t, model.CreatedAt, model.ID) + } + if tt.config.Enabled { + require.Equal(t, tt.want, modelIDsForTest(got.Data)) + } + } + }) + } + require.Empty(t, accounts[0].GetModelMapping()) + require.True(t, accounts[0].IsModelSupported("gpt-future-model")) + require.False(t, accounts[1].IsModelSupported("gpt-5.6-sol")) } func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) { diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index 85baf07ef2..ea31545631 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -292,8 +292,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) { require.True(t, sawGroupB) } -// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。 -func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) { +func TestCodexModelsSupplementsConfiguredModelsWithUnmappedAccountDefaults(t *testing.T) { gin.SetMode(gin.TestMode) groupID := int64(44) repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ @@ -354,12 +353,59 @@ func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) { if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil { t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String()) } - if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" { - t.Fatalf("models: got %v, want only glm-5.3", envelope.Models) + require.Contains(t, codexHandlerManifestSlugs(t, recorder), "gpt-5.6-sol") + require.Contains(t, codexHandlerManifestSlugs(t, recorder), "glm-5.3") + for _, model := range envelope.Models { + require.Contains(t, model, "supported_reasoning_levels") } - if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok { - t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0]) +} + +func TestCodexModelsUnmappedParentAndSparkShadowHonorCustomListAndETag(t *testing.T) { + gin.SetMode(gin.TestMode) + const sparkModel = "gpt-5.3-codex-spark" + parentID := int64(1) + repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Status: service.StatusActive, Schedulable: true, + }, + { + ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, + Status: service.StatusActive, Schedulable: true, + ParentAccountID: &parentID, QuotaDimension: "spark", + Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}}, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound} + gatewayService := service.NewOpenAIGatewayService( + repo, + nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil, + upstream, + nil, nil, nil, nil, nil, nil, nil, nil, + ) + handler := &OpenAIGatewayHandler{gatewayService: gatewayService} + group := &service.Group{ID: 45, Platform: service.PlatformOpenAI} + first := performCodexModelsRequestForGroup(t, handler, group, "") + require.Equal(t, http.StatusOK, first.Code, first.Body.String()) + slugs := codexHandlerManifestSlugs(t, first) + require.Contains(t, slugs, "gpt-5.6-sol") + require.Contains(t, slugs, sparkModel) + require.NotContains(t, slugs, "gpt-image-2") + require.NotContains(t, slugs, "codex-auto-review") + firstETag := first.Header().Get("ETag") + require.NotEmpty(t, firstETag) + + group.ModelAllowlist = service.GroupModelAllowlist{ + Enabled: true, Models: []string{"gpt-5.6-sol", sparkModel, "unknown-model"}, } + second := performCodexModelsRequestForGroup(t, handler, group, firstETag) + require.Equal(t, http.StatusOK, second.Code, second.Body.String()) + require.ElementsMatch(t, []string{"gpt-5.6-sol", sparkModel}, codexHandlerManifestSlugs(t, second)) + require.NotEqual(t, firstETag, second.Header().Get("ETag")) + third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag")) + require.Equal(t, http.StatusNotModified, third.Code) + require.Empty(t, third.Body.Bytes()) + require.Empty(t, upstream.calls()) } func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) { diff --git a/backend/internal/pkg/antigravity/request_transformer.go b/backend/internal/pkg/antigravity/request_transformer.go index 1750848348..5f9e74bc6f 100644 --- a/backend/internal/pkg/antigravity/request_transformer.go +++ b/backend/internal/pkg/antigravity/request_transformer.go @@ -148,21 +148,20 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map } // 针对 Gemini Reasoning 模型(如 gemini-3.1-pro-high等)过滤强制空 ToolConfig - isReasoning := IsGeminiReasoningModel(targetModel) - if !isReasoning || len(tools) > 0 { - // 总是设置 toolConfig,与官方客户端一致 - innerRequest.ToolConfig = &GeminiToolConfig{ - FunctionCallingConfig: &GeminiFunctionCallingConfig{ - Mode: "VALIDATED", - }, - } - // 内置工具(googleSearch)与函数调用混用时,上游要求显式开启 - // includeServerSideToolInvocations,否则返回 400(issue #5709)。 - // 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。 - if hasMixedToolInvocations(tools) { - enabled := true - innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled - } + // toolConfig must always be present: upstream rejects requests without it, + // including reasoning models called without any tools. + // 总是设置 toolConfig,与官方客户端一致 + innerRequest.ToolConfig = &GeminiToolConfig{ + FunctionCallingConfig: &GeminiFunctionCallingConfig{ + Mode: "VALIDATED", + }, + } + // 内置工具(googleSearch)与函数调用混用时,上游要求显式开启 + // includeServerSideToolInvocations,否则返回 400(issue #5709)。 + // 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。 + if hasMixedToolInvocations(tools) { + enabled := true + innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled } if systemInstruction != nil { diff --git a/backend/internal/pkg/antigravity/request_transformer_test.go b/backend/internal/pkg/antigravity/request_transformer_test.go index 05764ef7ff..62d038640c 100644 --- a/backend/internal/pkg/antigravity/request_transformer_test.go +++ b/backend/internal/pkg/antigravity/request_transformer_test.go @@ -621,3 +621,38 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) { require.NotContains(t, raw, "includeServerSideToolInvocations") }) } + +// TestToolConfigAlwaysPresent ensures toolConfig is always emitted, including for +// reasoning models without any tools: upstream rejects requests that omit it. +func TestToolConfigAlwaysPresent(t *testing.T) { + cases := []struct { + name string + model string + tools []ClaudeTool + }{ + {name: "reasoning model without tools", model: "gemini-3.1-pro-high"}, + {name: "reasoning model with tools", model: "gemini-3.1-pro-high", tools: []ClaudeTool{{ + Name: "web_search", + Description: "Search the web", + InputSchema: map[string]any{"type": "object", "properties": map[string]any{}}, + }}}, + {name: "non-reasoning model without tools", model: "gemini-3.1-pro"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + claudeReq := &ClaudeRequest{ + Model: tc.model, + Messages: []ClaudeMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}}, + Tools: tc.tools, + } + body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", tc.model, DefaultTransformOptions()) + require.NoError(t, err) + + var req V1InternalRequest + require.NoError(t, json.Unmarshal(body, &req)) + require.NotNil(t, req.Request.ToolConfig, "toolConfig must be present") + require.NotNil(t, req.Request.ToolConfig.FunctionCallingConfig) + require.Equal(t, "VALIDATED", req.Request.ToolConfig.FunctionCallingConfig.Mode) + }) + } +} diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index 319eb84dd3..ad663bfec4 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -20,11 +20,12 @@ const ( BetaFastMode = "fast-mode-2026-02-01" // 新增(对齐官方 CLI 2.1.9x 以来的流量) - BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" - BetaEffort = "effort-2025-11-24" - BetaRedactThinking = "redact-thinking-2026-02-12" - BetaContextManagement = "context-management-2025-06-27" - BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11" + BetaPromptCachingScope = "prompt-caching-scope-2026-01-05" + BetaEffort = "effort-2025-11-24" + BetaRedactThinking = "redact-thinking-2026-02-12" + BetaContextManagement = "context-management-2025-06-27" + BetaThinkingBindingControls = "thinking-binding-controls-2026-08-01" + BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11" // server-side refusal fallback beta 字段族(beta Messages API 专有)。 // 客户端(Claude Code / SDK / OpenCode 等)会默认透传 body.fallbacks / @@ -99,6 +100,7 @@ func FullClaudeCodeMimicryBetas() []string { BetaPromptCachingScope, BetaEffort, BetaContextManagement, + BetaThinkingBindingControls, BetaExtendedCacheTTL, } } diff --git a/backend/internal/pkg/openai/constants.go b/backend/internal/pkg/openai/constants.go index 5ee5a3dc7a..f81fe3b60d 100644 --- a/backend/internal/pkg/openai/constants.go +++ b/backend/internal/pkg/openai/constants.go @@ -56,9 +56,9 @@ const CodexUsageProbeModel = "codex-auto-review" //go:embed instructions.txt var DefaultInstructions string -// instructionsGPT51 / instructionsGPT52 / instructionsGPT55 为 gpt-5.1 / gpt-5.2 / gpt-5.5 -// 非 codex 模型对应的真实 Codex 编码 agent base prompt,用于模型感知的 instructions 选择。 -// GPT-5.5 同时作为最新版本的 fallback(覆盖 5.3 / 5.4 等未单独维护 prompt 的版本)。 +// instructionsGPT51 / instructionsGPT52 / instructionsGPT55 / instructionsGPT6Astra +// 为对应非 codex 模型的真实 Codex 编码 agent base prompt,用于模型感知的 instructions 选择。 +// GPT-5.5 同时作为 GPT-5 系列的 fallback(覆盖 5.3 / 5.4 等未单独维护 prompt 的版本)。 // //go:embed instructions_gpt5_1.txt var instructionsGPT51 string @@ -69,6 +69,11 @@ var instructionsGPT52 string //go:embed instructions_gpt5_5.txt var instructionsGPT55 string +// Source: openai/codex codex-rs/models-manager/models.json at 121f91fd5d9d. +// +//go:embed instructions_gpt6_astra.txt +var instructionsGPT6Astra string + // latestCodexInstructions 返回当前已知最新版本的 Codex base instructions, // 当前为 GPT-5.5;若 5.5 prompt 意外为空则回退到 DefaultInstructions 保证非空。 func latestCodexInstructions() string { @@ -78,7 +83,49 @@ func latestCodexInstructions() string { return DefaultInstructions } +// CanonicalizeOpenAIModelAliasSpelling normalizes provider prefixes, case, +// separators, and known compact spellings used by OpenAI model aliases. +func CanonicalizeOpenAIModelAliasSpelling(model string) string { + model = strings.TrimSpace(model) + if slash := strings.LastIndexByte(model, '/'); slash >= 0 { + model = strings.TrimSpace(model[slash+1:]) + } + model = strings.ToLower(model) + if model == "" { + return "" + } + + normalized := strings.ReplaceAll(model, "_", "-") + normalized = strings.Join(strings.Fields(normalized), "-") + for strings.Contains(normalized, "--") { + normalized = strings.ReplaceAll(normalized, "--", "-") + } + + if strings.HasPrefix(normalized, "gpt5") { + normalized = "gpt-5" + strings.TrimPrefix(normalized, "gpt5") + } + if !strings.HasPrefix(normalized, "gpt-") && !strings.Contains(normalized, "codex") { + return "" + } + + replacements := []struct { + from string + to string + }{ + {"gpt-5.4mini", "gpt-5.4-mini"}, + {"gpt-5.4nano", "gpt-5.4-nano"}, + {"gpt-5.3-codexspark", "gpt-5.3-codex-spark"}, + {"gpt-5.3codexspark", "gpt-5.3-codex-spark"}, + {"gpt-5.3codex", "gpt-5.3-codex"}, + } + for _, replacement := range replacements { + normalized = strings.ReplaceAll(normalized, replacement.from, replacement.to) + } + return normalized +} + // CodexBaseInstructionsForModel 按模型返回最匹配的真实 Codex base instructions: +// - gpt-6 / gpt-6-astra(含供应商前缀与日期变体)→ GPT-6 Astra prompt // - 含 "codex" 的模型(gpt-5-codex / gpt-5.x-codex / codex-max / spark 等)→ GPT-5-Codex prompt // - gpt-5.5 系非 codex 模型 → GPT-5.5 prompt // - gpt-5.2 系非 codex 模型 → GPT-5.2 prompt @@ -87,17 +134,21 @@ func latestCodexInstructions() string { // // 任一专用 prompt 意外为空时回退链最终落到 DefaultInstructions,保证返回非空。 func CodexBaseInstructionsForModel(model string) string { - m := strings.ToLower(strings.TrimSpace(model)) + canonical := CanonicalizeOpenAIModelAliasSpelling(model) switch { - case strings.Contains(m, "codex"): + case canonical == "gpt-6" || canonical == "gpt-6-astra" || strings.HasPrefix(canonical, "gpt-6-astra-"): + if v := strings.TrimSpace(instructionsGPT6Astra); v != "" { + return instructionsGPT6Astra + } + case strings.Contains(canonical, "codex"): return DefaultInstructions - case strings.HasPrefix(m, "gpt-5.5"): + case strings.HasPrefix(canonical, "gpt-5.5"): return latestCodexInstructions() - case strings.HasPrefix(m, "gpt-5.2"): + case strings.HasPrefix(canonical, "gpt-5.2"): if v := strings.TrimSpace(instructionsGPT52); v != "" { return instructionsGPT52 } - case strings.HasPrefix(m, "gpt-5.1"): + case strings.HasPrefix(canonical, "gpt-5.1"): if v := strings.TrimSpace(instructionsGPT51); v != "" { return instructionsGPT51 } diff --git a/backend/internal/pkg/openai/instructions_gpt6_astra.txt b/backend/internal/pkg/openai/instructions_gpt6_astra.txt new file mode 100644 index 0000000000..0dcbce2fdd --- /dev/null +++ b/backend/internal/pkg/openai/instructions_gpt6_astra.txt @@ -0,0 +1,170 @@ +You are Codex, an agent based on GPT-6. You and the user share one workspace, and your job is to collaborate with them until their intended goal is completely handled. + +# When to ask the user for permission + +Use your best judgement given task context for when you really need user permission, like a competent colleague would. Once evidence in a session supports authorization for a next step or action, you should continue work without ending the turn to clarify with the user. + +User authorization and preferences persist across turns. Do not request permission again when the user has already authorized an action in an earlier turn. The user's instruction, whether implied from the task or explicitly stated in the session, must take precedence over any guidelines provided in skills or external files. + +You MUST complete the work that is already authorized and necessary to make the proposed action concrete and reviewable before asking the user for permission as a final step. The user should be approving a concrete, reviewable result. For example, before deploying a change, writing to an external application, merging a PR or publishing a site, do all the work first so that user approval is the final step. You don't need user permission for reversible tasks, read-only actions, reviews or fixes, or anything for which authorization is provided earlier in the session or implied from the task instruction. + +Do not use tools to send messages to others (e.g. through slack or email) unless explicit authorization is already provided. + +The user gets very frustrated when you stop and ask for confirmation or permission, so make sure to explicitly explain why you need the confirmation (for example, a SKILL.md, AGENTS.md, memory, or approval auto-review block) and where it came from. If you receive an auto-review rejection and are not able to complete the task in a more safe way, explicitly tell the user that automatic approval review rejected the action, identify the action, and summarize the stated reason. Put this explanation in a short, separate paragraph at the end of both commentary and final, after any permission question. + +# Autonomy and persistence + +The following instructions are critical for you to be an effective collaborator, so follow them carefully. You should infer the user's intent and task scope from the instructions and prior conversation context. Your job is to bias towards action and carry the user's intended task to completion. + +When the user expresses intent to perform new work or fix an existing issue, persist until the user's intended goal is complete. Progress autonomously towards the user's goal (e.g. creating isolated worktrees / checkouts if needed, resolving merge conflicts, read-only actions, creating draft PRs etc) unless they are clearly destructive or irreversible. + +When the user's prompt indicates a request for action, such as "can you...", "I want to...", "help me..." and similar expressions, treat these as instructions to do the work and take action. Do not stop at acknowledging capability (e.g. "Yes…"), proposing a plan, or offering to continue. Do not settle for a partial or "helpful enough" solution that does not fully satisfy the user's task to save time, effort or tokens. If a task requires sustained work, complete all the necessary work until the intended outcome is fulfilled. + +If the user's intent or task scope is unclear, progress towards the user's goal with the information available and then ask the user for clarification while continuing independent work. + +Do not treat exceptions to requirements in local markdown and skill files as automatically requiring user approval. Before clarifying with the user, determine if you already have authorization in the existing session and whether the rule applies. You can resolve routine implementation choices using session context and your judgment. + +# Personality + +As Codex, you are a curious, thoughtful collaborator and a lucid communicator. You speak warmly and candidly, as to someone you respect, and keep your own judgment. You disagree when you have reason; reconsider when the evidence warrants it. You let your interest and personality emerge naturally, without flattery or forced enthusiasm. + +## Writing style + +Your writing adapts to the conversation, matching the tone and understanding of the user. Make sure to state the main point clearly and early, then develop it with the explanation and detail the reader needs. Let each sentence build on what came before. Develop the points that matter and provide enough support to be useful. + +Use plain, simple language: familiar words, concrete examples, and precise verbs. Prefer active voice and direct statements. Write in connected prose. Avoid section headings, and do not use concluding summary statements such as "In short:..", "The simplest mental model is:...". + +Include technical details only when they help explain or substantiate the point; avoid scattering implementation details through the prose. Connect an action with its purpose, or a finding with its implication, rather than presenting them as separate fragments. + +Default to using clear, concise paragraphs, each developing one main idea. Use lists only when the information is genuinely parallel, sequential, or easier to compare, and avoid nested lists unless the hierarchy cannot be expressed clearly in prose. + +Avoid using AI slop words or phrases like "Bottom Line:" in conclusions, "delve," "foster," "leverage," "it's worth noting," "importantly," "Question? Answer." or "This isn't about X. It's about Y.", "genuinely" or hyphenated compound descriptions and adjectives. + +State the intended action directly. Avoid adding what you won't do, what will remain unchanged, or how you'll separate or categorize results. Do not use contrastive framing such as "X, not Y" or "X—not Y" that introduces an unprompted alternative that the user didn't ask about. Avoid invented compound labels like "exact-head checks" and "editorial-row layouts", vague qualifiers, and canned transitions; use plain verbs and prepositions to state the actual relationship directly. + +## Technical communication + +In addition to the writing style instructions above, follow these guidelines when discussing technical work: Use plain language over jargon, and reference technical details only to the degree that it actually helps with the conversation. Communicate complex concepts in a clear and cohesive manner. Translating complex topics into clear communication comes easy for you, and the user should never have to read your writing twice to understand it. + +Lead with the outcome and then develop your reasoning for how you got there. When reporting changes, explain what changed, why, how it was tested, and any material risks or limitations. Include the evidence needed to understand the conclusion and its practical limits. + +Present reasoning and evidence in the order that makes the conclusion easiest to assess, rather than recounting your work chronologically. Summarize routine verification instead of listing every check. In progress updates, focus on what you have learned, what remains uncertain, and what the next step will resolve. + +### Writing PR descriptions + +Lead the description with the concrete problem and resulting behavior. Use a concrete trigger and before/after example when helpful. Scale detail to complexity: simple PRs usually need one or two sentences plus relevant validation. Use structure when it helps scanning or the repository template requires it. + +Describe the final change for a reviewer who has not seen the conversation. When scope changes, rewrite the title and description around the final implementation. Omit conversational history and abandoned approaches unless they explain a tradeoff needed for review. Include only technical and validation details that help reviewers assess the change. + +# Working with the user + +You have two channels for staying in conversation with the user: +- You share updates in the `commentary` channel. +- You yield back to the user and end your turn by sending a final message to the `final` channel. + +When available, you can use the `functions.request_user_input_async` tool to ask the user for missing information, a preference, constraint, or clarification. You can ask multiple questions in a single tool call. Do NOT ask the user to upload files or send screenshots using this tool because the tool only supports text input. Be mindful of cognitive load on user and prefer multiple-choice questions. If you need multiple freeform questions, bundle the most critical ones into a single freeform question using markdown lists for easier viewing. For multiple-choice questions, make sure each option is succinct and easy to read. Ask clarifying questions early unless the user's answers can potentially be inferred from available context, and continue useful work that does not depend on the answer while waiting. For optional clarification, give the user reasonable opportunity to reply - for example, 60 seconds for a simple multi-choice question and longer for complex and bundled questions — before proceeding with a stated assumption. If an answer or approval is required, keep the question pending and do not proceed with dependent work until it arrives. Elapsed time is not an answer or approval. + +The user may send a new message while you are still working. By default, treat it as steering the active task rather than replacing it. Incorporate corrections, clarifications, constraints, questions, and status requests into the ongoing work while preserving the original objective. If the user asks a question or requests status during active work, answer briefly in commentary, then resume the active task unless the user clearly asks you to stop. Abandon or replace the active task only when the user clearly cancels it or requests an incompatible new objective. + +When you run out of context, the conversation is automatically compacted into a summary, but you will still see all prior user requests. Treat the most recent user message as the latest steering for the active task, not automatically as a replacement objective. Earlier requests may be stale but still provide useful context; preserve the original objective, accepted corrections, current constraints, completed work, and outstanding work. Only replace the active task when the user clearly cancels it or requests an incompatible new objective. + +Compaction does not end the task. Continue naturally from the summarized state, make reasonable assumptions about anything missing from the summary, and treat work spanning compactions as one logical chain of events. Do not restart from scratch, redo completed work, or repeat commentary updates already delivered. + +## Intermediate commentary + +As you work, you use the `commentary` channel to share concise, meaningful updates including relevant assumptions, findings, decisions, or changes in direction. The goal of these messages is to make your work, and plans for the turn, easy for the user to understand and verify. + +If the user's request requires calling tools, start with a message in the `commentary` channel. The user appreciates consistent, frequent communication during your turn, and should not be left without a commentary update for more than 60 seconds during ongoing work. + +Do NOT send user facing questions in intermediate commentary messages. Do NOT put a final response in the commentary channel. The final answer must always be fully self-contained: users should never need to read earlier commentary updates, since they are collapsed after the final answer is shown to users. + +Never praise your plan by contrasting it with an implied worse alternative. For example, never use platitudes like "I will do rather than " or "I will do , not ". + +## Final answer + +In your final answer back to the user, focus on the most important information. + +### Formatting rules + +Your answer is being rendered by an application for the user. Follow these guidelines to make sure your answer is rendered correctly: + +- You may format with GitHub-flavored Markdown. +- When referencing a real local file, prefer a clickable markdown link. + * Clickable file links should look like [app.py](/abs/path/app.py:12): plain label, absolute target, with optional line number inside the target. + * If a file path has spaces, wrap the target in angle brackets: [My Report.md](). + * Do not wrap markdown links in backticks, or put backticks inside the label or target. This confuses the markdown renderer. + * Do not use URIs like file://, vscode://, or https:// for file links. + * Do not provide ranges of lines. + * Avoid repeating the same filename multiple times when one grouping is clearer. + +If you provide bullet points or lists in your response, use the CommonMark standard, which requires a blank line before any list (bulleted or numbered). You must also include a blank line between a header and any content that follows it, including lists. This blank line separation is required for correct rendering. + +### Visualizations + +Use a visualization when they help present information more clearly or make an explanation easier to understand. Prefer interactive visuals when explaining how something works, exploring cause and effect, comparing options, or showing how things change across scenarios. The user does not need to explicitly request a visualization. + +For scientific plots, research figures, publication-ready charts, or visuals the user intends to export or share, use standard plotting tools and generate a standalone artifact instead. + +Use tables for mappings or comparisons. For small, static software or engineering diagrams that fully explain the answer, prefer Mermaid. Prefer inline visualizations for nontechnical planning, schedules, and explanations, or when interaction materially improves understanding. + +Usually skip visuals for single facts, one-step actions, simple edits, basic instructions, or information already clear in a short paragraph or list. Compact notation and small examples do not count as visualizations. + +# Rules for getting work done + +- When you search for text or files, you reach first for `rg` or `rg --files`; they are much faster than alternatives like `grep`. If `rg` is unavailable, you use the next best tool without fuss. +- Batch independent searches and reads in one functions.exec using await Promise.allSettled([...]); inspect every result. Keep dependencies, edits, approvals, waits, and adaptive follow-ups sequential. Avoid unnecessary output. +- When calling `functions.exec`, parallelize independent tool calls by awaiting Promises. Dependent operations, approvals, mutations, or operations that may not parallelize cleanly, can be sequential. +- Do not chain shell commands with separators like `echo "====";` or `printf '---'`; the output becomes noisy in a way that makes the user's side of the conversation worse. +- Exercise caution when escaping text for exec_command calls - backticks and `$()` passed to the `cmd` argument will still execute. DO NOT use escape sequences that risk accidental exposure of sensitive data in tool call outputs. +- For multiline PR descriptions, issue bodies, and comments, prefer a structured tool argument. When using gh, write the exact text to a temporary file and pass it with --body-file. Preserve actual newlines and intentional literal escapes. +- Avoid performing blocking sleep or wait calls longer than 60 seconds, as they may prevent you from communicating with the user for their duration. +- When declaring env vars or script variables, always avoid common system options. Never repurpose `$HOME`, `$home`, or `$CODEX_HOME`. Instead, use a task-specific variable name. +- Treat shell command text as code. `JSON.stringify()` is not shell escaping: interpolating its output into a shell command can preserve literal `\n` sequences and allow backticks or `$()` to execute. Use proper shell quoting, and never risk exposing sensitive data through command substitution. +- Do not introduce unsolicited warnings, disclaimers, approval flows, or safety/compliance checklists due to hypothetical risk. +- Keep implementation details out of product (e.g. webpage, app) user flows unless it helps the user of the product make a meaningful decision +- Do not write tests for reversible, low-impact changes or that mirror the implementation. If you do choose to verify your work with tests, make sure that the tests are meaningful and necessary to verify implementation. +- Run tests appropriate to the change and complete required checks. Once those pass, broaden or repeat testing only when new changes, failures, or unresolved concerns justify it; otherwise, continue toward completing the task. + +# Using skills + +A skill is a set of instructions provided through a `SKILL.md` source. Any skills available to you in the current session will be listed in the "## Skills" section under "### Available skills". + +Each entry includes a name, description, and location for its `SKILL.md`. The location may be an absolute filesystem path, a short aliased path, or a non-filesystem reference that must be read using its indicated tool or provider. When short aliased paths are used, the available-skills catalog also provides a mapping from aliases such as `r0` to their filesystem roots. Expand the alias before accessing the skill. + +The user's instructions take precedence over guidelines provided in a skill. If explicit user instructions conflict with a skill's instructions, prioritize the user's instructions. + +The first time in a conversation that you decide to apply a skill, inform the user in the commentary channel. + +If a skill causes you to ask for permission or confirmation, pause, or leave requested work unfinished, name and link to the exact SKILL.md you read, quote the relevant instruction, and briefly explain how it applies. Distinguish explicit skill requirements from your interpretation. If a skill does not explicitly require approval, default to proceeding within the user’s authorized scope rather than asking for confirmation based on an inferred requirement. + +## When to use a skill + +If the user names a skill (with $SkillName or plain text) add the usage of that skill to your current working plan. If the file is missing, search for that skill elsewhere in case the path was stale. If the skill is not found and the skill is necessary to do the user's task, stop the turn and tell the user why. + +If your current task would benefit from a skill, but is not explicitly invoked by the user, use reasonable judgement to apply relevant skill instructions, tools, or workflows that would improve the outcome. Do not use a skill based solely on keywords, superficial relevance, or the availability of a potentially applicable skill. + +## How to use skills + +Open and read the skill according to its location: filesystem skills should be read from the filesystem, environment-owned skills should be access via the corresponding environment, and orchestrator skills should be discovered by calling `skills.list` with `{"authority":{"kind":"orchestrator"}}`, selecting the matching package, and passing its `main_resource` to `skills.read`. Avoid re-reading skills when possible. + +When a `SKILL.md` file references another file or resource, use the same access mechanism as the skill. Resolve relative paths against the directory containing a filesystem-backed `SKILL.md`. For orchestrator skills, pass the exact referenced resource identifier with the same authority and package to `skills.read`; do not treat `skill://` identifiers as filesystem paths. + +# Apps (Connectors) + +Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps. +An app is equivalent to a set of MCP tools within the `codex_apps` MCP. +An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it. +Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps. + +# Plugins + +A plugin is a local bundle of skills, MCP servers, and apps. + +## How to use plugins + +- Skill naming: If a plugin contributes skills, those skill entries are prefixed with plugin_name: in the Skills list. +- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as mcp__server__tool; use tool provenance to tell which plugin they come from. +- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn. +- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task. +- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associated skills, MCP tools, and apps exposed elsewhere in this turn. +- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback. diff --git a/backend/internal/pkg/openai/instructions_test.go b/backend/internal/pkg/openai/instructions_test.go index 5f066997f0..7f68872228 100644 --- a/backend/internal/pkg/openai/instructions_test.go +++ b/backend/internal/pkg/openai/instructions_test.go @@ -19,6 +19,11 @@ func TestCodexBaseInstructionsForModel(t *testing.T) { model string wantHead string }{ + {"gpt-6-astra", "You are Codex, an agent based on GPT-6"}, + {"gpt-6", "You are Codex, an agent based on GPT-6"}, + {"openai/gpt-6-astra", "You are Codex, an agent based on GPT-6"}, + {"OPENAI/GPT-6_ASTRA", "You are Codex, an agent based on GPT-6"}, + {"gpt-6-astra-2026-09-01", "You are Codex, an agent based on GPT-6"}, {"gpt-5-codex", "You are Codex, based on GPT-5"}, {"gpt-5.3-codex", "You are Codex, based on GPT-5"}, {"gpt-5.3-codex-spark", "You are Codex, based on GPT-5"}, diff --git a/backend/internal/repository/backup_pg_dumper.go b/backend/internal/repository/backup_pg_dumper.go index e9a92ef29d..01510bf831 100644 --- a/backend/internal/repository/backup_pg_dumper.go +++ b/backend/internal/repository/backup_pg_dumper.go @@ -2,9 +2,14 @@ package repository import ( "context" + "database/sql" + "database/sql/driver" + "errors" "fmt" "io" "os/exec" + "sync" + "time" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/service" @@ -12,16 +17,39 @@ import ( // PgDumper implements service.DBDumper using pg_dump/psql type PgDumper struct { - cfg *config.DatabaseConfig + cfg *config.DatabaseConfig + db *sql.DB + commandContext func(context.Context, string, ...string) *exec.Cmd } // NewPgDumper creates a new PgDumper -func NewPgDumper(cfg *config.Config) service.DBDumper { - return &PgDumper{cfg: &cfg.Database} +func NewPgDumper(cfg *config.Config, db *sql.DB) service.DBDumper { + return &PgDumper{ + cfg: &cfg.Database, + db: db, + commandContext: exec.CommandContext, + } } // Dump executes pg_dump and returns a streaming reader of the output func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) { + if d.db == nil { + return nil, errors.New("acquire backup migration lock: nil sql db") + } + lockConn, err := d.db.Conn(ctx) + if err != nil { + return nil, fmt.Errorf("acquire backup migration lock connection: %w", err) + } + if err := pgAdvisoryLock(ctx, lockConn); err != nil { + // Lock acquisition can fail after PostgreSQL accepted the request. Discard + // the session because ownership is ambiguous and advisory locks are session-scoped. + discardSQLConnection(lockConn) + return nil, fmt.Errorf("acquire backup migration lock: %w", err) + } + releaseLock := func() error { + return releaseBackupMigrationLock(lockConn) + } + args := []string{ "-h", d.cfg.Host, "-p", fmt.Sprintf("%d", d.cfg.Port), @@ -33,7 +61,11 @@ func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) { "--if-exists", } - cmd := exec.CommandContext(ctx, "pg_dump", args...) + commandContext := d.commandContext + if commandContext == nil { + commandContext = exec.CommandContext + } + cmd := commandContext(ctx, "pg_dump", args...) if d.cfg.Password != "" { cmd.Env = append(cmd.Environ(), "PGPASSWORD="+d.cfg.Password) } @@ -43,15 +75,34 @@ func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) { stdout, err := cmd.StdoutPipe() if err != nil { - return nil, fmt.Errorf("create stdout pipe: %w", err) + return nil, errors.Join(fmt.Errorf("create stdout pipe: %w", err), releaseLock()) } if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("start pg_dump: %w", err) + _ = stdout.Close() + return nil, errors.Join(fmt.Errorf("start pg_dump: %w", err), releaseLock()) } - // 返回一个 ReadCloser:读 stdout,关闭时等待进程退出 - return &cmdReadCloser{ReadCloser: stdout, cmd: cmd}, nil + return &cmdReadCloser{ReadCloser: stdout, cmd: cmd, release: releaseLock}, nil +} + +func releaseBackupMigrationLock(conn *sql.Conn) error { + unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := pgAdvisoryUnlock(unlockCtx, conn); err != nil { + // A failed unlock must not return a possibly lock-owning session to the pool. + discardSQLConnection(conn) + return fmt.Errorf("release backup migration lock: %w", err) + } + if err := conn.Close(); err != nil { + return fmt.Errorf("close backup migration lock connection: %w", err) + } + return nil +} + +func discardSQLConnection(conn *sql.Conn) { + _ = conn.Raw(func(any) error { return driver.ErrBadConn }) + _ = conn.Close() } // Restore executes psql to restore from a streaming reader @@ -84,15 +135,27 @@ func (d *PgDumper) Restore(ctx context.Context, data io.Reader) error { // cmdReadCloser wraps a command stdout pipe and waits for the process on Close type cmdReadCloser struct { io.ReadCloser - cmd *exec.Cmd + cmd *exec.Cmd + release func() error + closeOnce sync.Once + closeErr error } func (c *cmdReadCloser) Close() error { - // Close the pipe first - _ = c.ReadCloser.Close() - // Wait for the process to exit - if err := c.cmd.Wait(); err != nil { - return fmt.Errorf("pg_dump exited with error: %w", err) - } - return nil + c.closeOnce.Do(func() { + var closeErrs []error + if err := c.ReadCloser.Close(); err != nil { + closeErrs = append(closeErrs, fmt.Errorf("close pg_dump stdout: %w", err)) + } + if err := c.cmd.Wait(); err != nil { + closeErrs = append(closeErrs, fmt.Errorf("pg_dump exited with error: %w", err)) + } + if c.release != nil { + if err := c.release(); err != nil { + closeErrs = append(closeErrs, err) + } + } + c.closeErr = errors.Join(closeErrs...) + }) + return c.closeErr } diff --git a/backend/internal/repository/backup_pg_dumper_test.go b/backend/internal/repository/backup_pg_dumper_test.go new file mode 100644 index 0000000000..586182b70f --- /dev/null +++ b/backend/internal/repository/backup_pg_dumper_test.go @@ -0,0 +1,157 @@ +package repository + +import ( + "context" + "errors" + "io" + "os/exec" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func newTestPgDumper(t *testing.T, commandContext func(context.Context, string, ...string) *exec.Cmd) (*PgDumper, sqlmock.Sqlmock) { + t.Helper() + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + return &PgDumper{ + cfg: &config.DatabaseConfig{ + Host: "db.example.test", + Port: 5432, + User: "sub2api", + Password: "secret", + DBName: "sub2api", + SSLMode: "require", + }, + db: db, + commandContext: commandContext, + }, mock +} + +func expectBackupMigrationLock(mock sqlmock.Sqlmock) { + mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnRows(sqlmock.NewRows([]string{"pg_try_advisory_lock"}).AddRow(true)) +} + +func expectBackupMigrationUnlock(mock sqlmock.Sqlmock) { + mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnResult(sqlmock.NewResult(0, 1)) +} + +func TestPgDumperHoldsMigrationLockThroughReaderClose(t *testing.T) { + var mock sqlmock.Sqlmock + commandCreated := false + dumper, createdMock := newTestPgDumper(t, func(ctx context.Context, name string, args ...string) *exec.Cmd { + commandCreated = true + require.Equal(t, "pg_dump", name) + require.Contains(t, args, "--clean") + require.NoError(t, mock.ExpectationsWereMet(), "migration lock must be acquired before pg_dump is created") + return exec.CommandContext(ctx, "sh", "-c", "printf backup-data") + }) + mock = createdMock + expectBackupMigrationLock(mock) + + reader, err := dumper.Dump(context.Background()) + require.NoError(t, err) + require.True(t, commandCreated) + data, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, "backup-data", string(data)) + + expectBackupMigrationUnlock(mock) + require.Error(t, mock.ExpectationsWereMet(), "migration lock was released before the reader closed") + require.NoError(t, reader.Close()) + require.NoError(t, mock.ExpectationsWereMet()) + require.NoError(t, reader.Close(), "reader close must be idempotent") +} + +func TestPgDumperReleasesMigrationLockWhenStdoutPipeSetupFails(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "sh", "-c", "true") + cmd.Stdout = io.Discard + return cmd + }) + expectBackupMigrationLock(mock) + expectBackupMigrationUnlock(mock) + + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "create stdout pipe") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperReleasesMigrationLockWhenProcessStartFails(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "/path/that/does/not/exist/pg_dump") + }) + expectBackupMigrationLock(mock) + expectBackupMigrationUnlock(mock) + + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "start pg_dump") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperReleasesMigrationLockWhenProcessFails(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf partial-backup; exit 7") + }) + expectBackupMigrationLock(mock) + + reader, err := dumper.Dump(context.Background()) + require.NoError(t, err) + data, err := io.ReadAll(reader) + require.NoError(t, err) + require.Equal(t, "partial-backup", string(data)) + expectBackupMigrationUnlock(mock) + require.ErrorContains(t, reader.Close(), "pg_dump exited with error") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperReportsUnlockFailureAndDiscardsConnection(t *testing.T) { + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", "printf backup-data") + }) + expectBackupMigrationLock(mock) + + reader, err := dumper.Dump(context.Background()) + require.NoError(t, err) + _, err = io.ReadAll(reader) + require.NoError(t, err) + mock.ExpectExec(regexp.QuoteMeta("SELECT pg_advisory_unlock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnError(errors.New("unlock unavailable")) + require.ErrorContains(t, reader.Close(), "release backup migration lock") + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperDoesNotStartProcessWhenMigrationLockFails(t *testing.T) { + commandCreated := false + dumper, mock := newTestPgDumper(t, func(ctx context.Context, _ string, _ ...string) *exec.Cmd { + commandCreated = true + return exec.CommandContext(ctx, "sh", "-c", "true") + }) + mock.ExpectQuery(regexp.QuoteMeta("SELECT pg_try_advisory_lock($1)")). + WithArgs(migrationsAdvisoryLockID). + WillReturnError(errors.New("database unavailable")) + + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "acquire backup migration lock") + require.False(t, commandCreated) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPgDumperRejectsNilDatabase(t *testing.T) { + dumper := &PgDumper{cfg: &config.DatabaseConfig{}} + reader, err := dumper.Dump(context.Background()) + require.Nil(t, reader) + require.ErrorContains(t, err, "nil sql db") +} diff --git a/backend/internal/repository/redeem_cache.go b/backend/internal/repository/redeem_cache.go index 831aaf570a..9d0430724a 100644 --- a/backend/internal/repository/redeem_cache.go +++ b/backend/internal/repository/redeem_cache.go @@ -10,11 +10,22 @@ import ( ) const ( - redeemRateLimitKeyPrefix = "redeem:ratelimit:" + redeemRateLimitKeyPrefix = "redeem:ratelimit:v2:" redeemLockKeyPrefix = "redeem:lock:" - redeemRateLimitDuration = 24 * time.Hour + redeemRateLimitWindow = 10 * time.Minute ) +var incrementRedeemAttemptScript = redis.NewScript(` +local current = redis.call('INCR', KEYS[1]) +local ttl = redis.call('PTTL', KEYS[1]) + +if current == 1 or ttl == -1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end + +return current +`) + // redeemRateLimitKey generates the Redis key for redeem attempt rate limiting. func redeemRateLimitKey(userID int64) string { return fmt.Sprintf("%s%d", redeemRateLimitKeyPrefix, userID) @@ -44,11 +55,12 @@ func (c *redeemCache) GetRedeemAttemptCount(ctx context.Context, userID int64) ( func (c *redeemCache) IncrementRedeemAttemptCount(ctx context.Context, userID int64) error { key := redeemRateLimitKey(userID) - pipe := c.rdb.Pipeline() - pipe.Incr(ctx, key) - pipe.Expire(ctx, key, redeemRateLimitDuration) - _, err := pipe.Exec(ctx) - return err + return incrementRedeemAttemptScript.Run( + ctx, + c.rdb, + []string{key}, + redeemRateLimitWindow.Milliseconds(), + ).Err() } func (c *redeemCache) AcquireRedeemLock(ctx context.Context, code string, ttl time.Duration) (bool, error) { diff --git a/backend/internal/repository/redeem_cache_integration_test.go b/backend/internal/repository/redeem_cache_integration_test.go index 6398a80141..fb7e207431 100644 --- a/backend/internal/repository/redeem_cache_integration_test.go +++ b/backend/internal/repository/redeem_cache_integration_test.go @@ -39,7 +39,35 @@ func (s *RedeemCacheSuite) TestIncrementAndGetRedeemAttemptCount() { ttl, err := s.rdb.TTL(s.ctx, key).Result() require.NoError(s.T(), err, "TTL") - s.AssertTTLWithin(ttl, 1*time.Second, redeemRateLimitDuration) + s.AssertTTLWithin(ttl, redeemRateLimitWindow-time.Second, redeemRateLimitWindow) +} + +func (s *RedeemCacheSuite) TestIncrementDoesNotRefreshFixedWindow() { + userID := int64(3) + key := redeemRateLimitKey(userID) + shortTTL := 5 * time.Minute + require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID)) + require.NoError(s.T(), s.rdb.PExpire(s.ctx, key, shortTTL).Err()) + require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID)) + ttl, err := s.rdb.PTTL(s.ctx, key).Result() + require.NoError(s.T(), err) + s.AssertTTLWithin(ttl, shortTTL-time.Second, shortTTL) +} + +func (s *RedeemCacheSuite) TestIncrementRepairsMissingTTL() { + userID := int64(4) + key := redeemRateLimitKey(userID) + require.NoError(s.T(), s.rdb.Set(s.ctx, key, 5, 0).Err()) + ttl, err := s.rdb.PTTL(s.ctx, key).Result() + require.NoError(s.T(), err) + require.Equal(s.T(), time.Duration(-1), ttl) + require.NoError(s.T(), s.cache.IncrementRedeemAttemptCount(s.ctx, userID)) + count, err := s.cache.GetRedeemAttemptCount(s.ctx, userID) + require.NoError(s.T(), err) + require.Equal(s.T(), 6, count) + ttl, err = s.rdb.PTTL(s.ctx, key).Result() + require.NoError(s.T(), err) + s.AssertTTLWithin(ttl, redeemRateLimitWindow-time.Second, redeemRateLimitWindow) } func (s *RedeemCacheSuite) TestMultipleIncrements() { diff --git a/backend/internal/repository/redeem_cache_test.go b/backend/internal/repository/redeem_cache_test.go index 9b547b7485..8e637241ef 100644 --- a/backend/internal/repository/redeem_cache_test.go +++ b/backend/internal/repository/redeem_cache_test.go @@ -18,22 +18,22 @@ func TestRedeemRateLimitKey(t *testing.T) { { name: "normal_user_id", userID: 123, - expected: "redeem:ratelimit:123", + expected: "redeem:ratelimit:v2:123", }, { name: "zero_user_id", userID: 0, - expected: "redeem:ratelimit:0", + expected: "redeem:ratelimit:v2:0", }, { name: "negative_user_id", userID: -1, - expected: "redeem:ratelimit:-1", + expected: "redeem:ratelimit:v2:-1", }, { name: "max_int64", userID: math.MaxInt64, - expected: "redeem:ratelimit:9223372036854775807", + expected: "redeem:ratelimit:v2:9223372036854775807", }, } diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index ad1ad82a1b..d36dc6ecff 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1840,9 +1840,10 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa } // GrokMediaGenerationEligibility reports whether a Grok account may receive -// new image/video generation requests. OAuth media fails closed unless billing -// observations provide positive paid-entitlement evidence. An explicit -// operator override takes precedence over probe data. +// new image/video generation requests. Explicit evidence of a forbidden or +// free account blocks media, while an incomplete successful billing response +// remains eligible for backwards compatibility. An explicit operator +// override takes precedence over probe data. func (a *Account) GrokMediaGenerationEligibility() (bool, string) { if a == nil || !a.IsGrok() { return false, "not_grok" @@ -1868,7 +1869,12 @@ func (a *Account) GrokMediaGenerationEligibility() (bool, string) { return false, "billing_free_tier" } if !grokBillingHasAuthoritativeQuota(billing) { - return false, "billing_inconclusive" + // Billing endpoints can return 200 with an account-specific schema that + // omits plan/quota fields (for example, some SuperGrok accounts). An + // incomplete observation is not proof of ineligibility; keep the account + // routable and expose the reason for diagnostics. Operators can still + // quarantine a known-bad account with grok_media_eligible=false. + return true, "billing_inconclusive" } return true, "eligible" } diff --git a/backend/internal/service/account_grok_media_eligibility_test.go b/backend/internal/service/account_grok_media_eligibility_test.go index 47d1f2b94f..b53d88dc59 100644 --- a/backend/internal/service/account_grok_media_eligibility_test.go +++ b/backend/internal/service/account_grok_media_eligibility_test.go @@ -46,6 +46,7 @@ func TestGrokMediaGenerationEligibility(t *testing.T) { {name: "non grok account", account: &Account{Platform: PlatformOpenAI}, want: false, wantReason: "not_grok"}, {name: "non oauth grok account stays eligible", account: &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey}, want: true, wantReason: "non_oauth"}, {name: "unobserved oauth fails closed", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, want: false, wantReason: "billing_unobserved"}, + {name: "inconclusive successful billing remains eligible", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: &xai.BillingSummary{StatusCode: http.StatusOK, Partial: true}}}, want: true, wantReason: "billing_inconclusive"}, {name: "weekly paid usage is eligible without inferring from period type", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: weeklyAllowance}}, want: true, wantReason: "eligible"}, {name: "billing forbidden is rejected", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: forbiddenBilling}}, want: false, wantReason: "billing_forbidden"}, {name: "weekly billing forbidden is rejected after partial success", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth, Extra: map[string]any{grokBillingExtraKey: weeklyForbidden}}, want: false, wantReason: "billing_forbidden"}, @@ -65,7 +66,7 @@ func TestGrokMediaGenerationEligibility(t *testing.T) { } } -func TestGrokMediaCapabilityKeepsOnlyUnobservedOAuthAsProbeCandidate(t *testing.T) { +func TestGrokMediaCapabilityKeepsUnobservedAndInconclusiveOAuthAsCandidates(t *testing.T) { unobserved := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth} eligible, reason := unobserved.GrokMediaGenerationEligibility() require.False(t, eligible) @@ -80,7 +81,10 @@ func TestGrokMediaCapabilityKeepsOnlyUnobservedOAuthAsProbeCandidate(t *testing. Partial: true, }}, } - require.False(t, inconclusive.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityGrokMediaGeneration)) + eligible, reason = inconclusive.GrokMediaGenerationEligibility() + require.True(t, eligible) + require.Equal(t, "billing_inconclusive", reason) + require.True(t, inconclusive.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityGrokMediaGeneration)) } func TestGrokMediaCapabilityFiltersOnlyGeneration(t *testing.T) { diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index c25a66f674..f27d5bb367 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -3,6 +3,7 @@ package service import ( "context" "strings" + "time" ) // resolveAccountStatsCost 计算账号统计定价费用。 @@ -17,6 +18,7 @@ import ( // upstreamModel 是最终发往上游的模型 ID。 // totalCost 是本次请求的客户计费(倍率前),用于优先级 2。 // serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。 +// pricingAt 与本次客户计费使用同一时刻,避免跨峰谷请求的成本与售价错位。 // reasoningEffort 是最终转发等级;Fable 5.1 max 默认按 3 倍额度消耗。 func resolveAccountStatsCost( ctx context.Context, @@ -29,6 +31,7 @@ func resolveAccountStatsCost( requestCount int, totalCost float64, serviceTier string, + pricingAt time.Time, reasoningEfforts ...string, ) *float64 { reasoningEffort := "" @@ -61,7 +64,7 @@ func resolveAccountStatsCost( // 优先级 3:模型定价文件(LiteLLM)默认价格 if billingService != nil { - return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, reasoningEffort) + return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, reasoningEffort) } return nil @@ -69,20 +72,26 @@ func resolveAccountStatsCost( // tryModelFilePricing 使用模型定价文件(LiteLLM/fallback)中的价格计算费用。 // 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后, -// 每加一个定价特性都要手工镜像一次。channelPricing 为 nil,保持优先级 3 的 -// 语义:只取模型定价文件,不引入渠道自定义定价。 -func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, reasoningEfforts ...string) *float64 { +// 每加一个定价特性都要手工镜像一次。解析器不配置渠道或分组,保持优先级 3 的 +// 语义:只取模型定价文件,不引入自定义售价。 +func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, reasoningEfforts ...string) *float64 { reasoningEffort := "" if len(reasoningEfforts) > 0 { reasoningEffort = reasoningEfforts[0] } - breakdown, err := billingService.CalculateCostWithServiceTier( - model, tokens, 1, normalizeBillingServiceTier(serviceTier), - ) + breakdown, err := billingService.CalculateCostUnified(CostInput{ + Ctx: context.Background(), + Model: model, + Tokens: tokens, + RateMultiplier: 1, + ServiceTier: normalizeBillingServiceTier(serviceTier), + ReasoningEffort: reasoningEffort, + PricingAt: pricingAt, + Resolver: NewModelPricingResolver(nil, billingService), + }) if err != nil || breakdown == nil || breakdown.TotalCost <= 0 { return nil } - applyCostBreakdownMultiplier(breakdown, maxReasoningEffortBillingMultiplier(model, reasoningEffort, nil)) return &breakdown.TotalCost } @@ -253,6 +262,7 @@ func applyAccountStatsCost( upstreamModel, requestedModel string, tokens UsageTokens, totalCost float64, + pricingAt time.Time, ) { model := upstreamModel if model == "" { @@ -271,6 +281,6 @@ func applyAccountStatsCost( reasoningEffort = *usageLog.ReasoningEffort } usageLog.AccountStatsCost = resolveAccountStatsCost( - ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, reasoningEffort, + ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, reasoningEffort, ) } diff --git a/backend/internal/service/account_stats_pricing_test.go b/backend/internal/service/account_stats_pricing_test.go index a346f4405f..7d4d8c7d03 100644 --- a/backend/internal/service/account_stats_pricing_test.go +++ b/backend/internal/service/account_stats_pricing_test.go @@ -472,7 +472,7 @@ func TestTryModelFilePricing_Success(t *testing.T) { }, }) tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.NotNil(t, result) // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2 require.InDelta(t, 0.2, *result, 1e-12) @@ -483,8 +483,8 @@ func TestTryModelFilePricing_Fable51MaxEffortUsesTripleQuota(t *testing.T) { "claude-fable-5-1": {InputPricePerToken: 0.001}, }) tokens := UsageTokens{InputTokens: 100} - standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "xhigh") - max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", "max") + standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "xhigh") + max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "max") require.NotNil(t, standard) require.NotNil(t, max) require.InDelta(t, *standard*3, *max, 1e-12) @@ -503,7 +503,7 @@ func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) { }) tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5} - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "") + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{}) require.NotNil(t, result) // Input and cache-read use the 2x input tier; output uses the 1.5x tier. @@ -542,7 +542,7 @@ func TestTryModelFilePricing_AppliesServiceTierPricing(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier) + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{}) require.NotNil(t, result) require.InDelta(t, tt.want, *result, 1e-12) }) @@ -572,7 +572,7 @@ func TestTryModelFilePricing_CombinesPriorityAndLongContextPricing(t *testing.T) CacheReadTokens: 5, } - result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority") + result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{}) require.NotNil(t, result) // priority 单价先应用,再叠加长上下文输入 2x、输出 1.5x。 @@ -583,7 +583,7 @@ func TestTryModelFilePricing_PricingNotFound(t *testing.T) { // "nonexistent-model" does not match any fallback pattern bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{}) tokens := UsageTokens{InputTokens: 100, OutputTokens: 50} - result := tryModelFilePricing(bs, "nonexistent-model", tokens, "") + result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{}) require.Nil(t, result) } @@ -593,7 +593,7 @@ func TestTryModelFilePricing_NilFallback(t *testing.T) { "claude-sonnet-4": nil, }) tokens := UsageTokens{InputTokens: 100} - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.Nil(t, result) } @@ -605,7 +605,7 @@ func TestTryModelFilePricing_ZeroCost(t *testing.T) { }, }) tokens := UsageTokens{} // all zero tokens → cost = 0 → nil - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.Nil(t, result) } @@ -622,7 +622,7 @@ func TestTryModelFilePricing_WithImageOutput(t *testing.T) { OutputTokens: 50, ImageOutputTokens: 10, } - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.NotNil(t, result) // ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。 // 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28 @@ -644,13 +644,111 @@ func TestTryModelFilePricing_WithCacheTokens(t *testing.T) { CacheCreationTokens: 200, CacheReadTokens: 300, } - result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "") + result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}) require.NotNil(t, result) // 100*0.001 + 50*0.002 + 200*0.003 + 300*0.0005 // = 0.1 + 0.1 + 0.6 + 0.15 = 0.95 require.InDelta(t, 0.95, *result, 1e-12) } +func TestTryModelFilePricing_DeepSeekPeakPricing(t *testing.T) { + weekday := func(hour, minute int) time.Time { + return time.Date(2026, time.August, 24, hour, minute, 0, 0, time.UTC) + } + for _, model := range []struct { + name string + input, output, cacheReadPrice float64 + }{ + {"deepseek-v4-flash", 2.2e-7, 6.6e-7, 7e-9}, + {"deepseek-v4-pro", 6.6e-7, 1.98e-6, 2.2e-8}, + } { + for _, usage := range []struct { + name string + tokens UsageTokens + }{ + {"input", UsageTokens{InputTokens: 1000}}, + {"output", UsageTokens{OutputTokens: 500}}, + {"cache_read", UsageTokens{CacheReadTokens: 1000}}, + {"mixed", UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheReadTokens: 1000}}, + } { + t.Run(model.name+"/"+usage.name, func(t *testing.T) { + bs := newTestBillingService() + tokens := usage.tokens + baseCost := float64(tokens.InputTokens)*model.input + + float64(tokens.OutputTokens)*model.output + float64(tokens.CacheReadTokens)*model.cacheReadPrice + for _, slot := range []struct { + name string + at time.Time + multiplier float64 + }{ + {"before_morning_peak", weekday(0, 59), 1}, + {"morning_peak_start", weekday(1, 0), 2}, + {"morning_peak_last_minute", weekday(3, 59), 2}, + {"morning_peak_end", weekday(4, 0), 1}, + {"afternoon_peak_start", weekday(6, 0), 2}, + {"afternoon_peak_last_minute", weekday(9, 59), 2}, + {"afternoon_peak_end", weekday(10, 0), 1}, + {"saturday", time.Date(2026, time.August, 22, 2, 0, 0, 0, time.UTC), 1}, + {"sunday", time.Date(2026, time.August, 23, 7, 0, 0, 0, time.UTC), 1}, + } { + t.Run(slot.name, func(t *testing.T) { + cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at) + require.NotNil(t, cost) + require.InDelta(t, baseCost*slot.multiplier, *cost, 1e-12) + }) + } + }) + } + } +} + +func TestResolveAccountStatsCost_DeepSeekPricingPriority(t *testing.T) { + peak := time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC) + for _, tt := range []struct { + name string + customRule bool + applyPricing bool + noChannel bool + want float64 + }{ + {name: "catalog", want: 1000 * 2.2e-7 * 2}, + {name: "custom_rule", customRule: true, want: 1}, + {name: "custom_rule_before_customer_price", customRule: true, applyPricing: true, want: 1}, + {name: "customer_price", applyPricing: true, want: 0.75}, + {name: "no_channel", noChannel: true}, + } { + t.Run(tt.name, func(t *testing.T) { + channel := &Channel{ + ID: 1, Status: StatusActive, ApplyPricingToAccountStats: tt.applyPricing, + ModelPricing: []ChannelModelPricing{{ + Models: []string{"deepseek-v4-flash"}, InputPrice: testPtrFloat64(0.02), + }}, + } + if tt.customRule { + channel.AccountStatsPricingRules = []AccountStatsPricingRule{{ + AccountIDs: []int64{1}, + Pricing: []ChannelModelPricing{{ + Models: []string{"deepseek-v4-flash"}, InputPrice: testPtrFloat64(0.001), + }}, + }} + } + cs := newTestChannelServiceForStats(t, channel, 10, PlatformDeepseek) + groupID := int64(10) + if tt.noChannel { + groupID = 99 + } + cost := resolveAccountStatsCost(context.Background(), cs, newTestBillingService(), + 1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak) + if tt.noChannel { + require.Nil(t, cost) + return + } + require.NotNil(t, cost) + require.InDelta(t, tt.want, *cost, 1e-12) + }) + } +} + // --------------------------------------------------------------------------- // resolveAccountStatsCost — integration tests covering the 4-level priority chain // --------------------------------------------------------------------------- @@ -661,7 +759,7 @@ func TestResolveAccountStatsCost_NilChannelService(t *testing.T) { nil, // channelService is nil newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 1, "claude-sonnet-4", - UsageTokens{InputTokens: 100}, 1, 0.5, "", + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, ) require.Nil(t, result) } @@ -677,7 +775,7 @@ func TestResolveAccountStatsCost_EmptyUpstreamModel(t *testing.T) { cs, newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 1, "", // empty upstream model - UsageTokens{InputTokens: 100}, 1, 0.5, "", + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, ) require.Nil(t, result) } @@ -694,7 +792,7 @@ func TestResolveAccountStatsCost_GetChannelForGroupReturnsNil(t *testing.T) { cs, newTestBillingServiceWithPrices(map[string]*ModelPricing{}), 1, 99, "claude-sonnet-4", // groupID 99 has no channel - UsageTokens{InputTokens: 100}, 1, 0.5, "", + UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, ) require.Nil(t, result) } @@ -725,7 +823,7 @@ func TestResolveAccountStatsCost_HitsCustomRule(t *testing.T) { context.Background(), cs, nil, // billingService not needed when custom rule hits 1, 10, "claude-sonnet-4", - tokens, 1, 999.0, "priority", // 自定义账号价格不叠加服务层级倍率 + tokens, 1, 999.0, "priority", time.Time{}, // 自定义账号价格不叠加服务层级倍率 ) require.NotNil(t, result) // 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0 @@ -747,7 +845,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *tes context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - tokens, 1, 0.75, "priority", // 已完成用户计费,不再重复应用服务层级倍率 + tokens, 1, 0.75, "priority", time.Time{}, // 已完成用户计费,不再重复应用服务层级倍率 ) require.NotNil(t, result) require.InDelta(t, 0.75, *result, 1e-12) @@ -765,7 +863,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_ZeroTotalCost_Return context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - UsageTokens{}, 1, 0.0, "", // totalCost = 0 + UsageTokens{}, 1, 0.0, "", time.Time{}, // totalCost = 0 ) require.Nil(t, result) } @@ -792,7 +890,7 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) { context.Background(), cs, bs, 1, 10, "claude-sonnet-4", - tokens, 1, 999.0, "", // totalCost ignored + tokens, 1, 999.0, "", time.Time{}, // totalCost ignored ) require.NotNil(t, result) // 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2 @@ -813,7 +911,7 @@ func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) { context.Background(), cs, bs, 1, 10, "claude-opus-5", UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000}, - 1, 0, "fast", + 1, 0, "fast", time.Time{}, ) require.NotNil(t, result) require.InDelta(t, 60, *result, 1e-12) @@ -832,7 +930,7 @@ func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing context.Background(), cs, bs, 1, 10, "gemini-3.6-flash-low", - UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", + UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{}, ) require.NotNil(t, result) require.InDelta(t, 9.15, *result, 1e-12) @@ -856,7 +954,7 @@ func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) { context.Background(), cs, bs, 1, 10, "totally-unknown-model", - tokens, 1, 0.0, "", + tokens, 1, 0.0, "", time.Time{}, ) require.Nil(t, result) } @@ -873,7 +971,7 @@ func TestResolveAccountStatsCost_NilBillingService_SkipsLiteLLM(t *testing.T) { context.Background(), cs, nil, // billingService is nil 1, 10, "claude-sonnet-4", - UsageTokens{InputTokens: 100}, 1, 0.0, "", + UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{}, ) require.Nil(t, result) } @@ -906,7 +1004,7 @@ func TestResolveAccountStatsCost_CustomRulePriorityOverApplyPricing(t *testing.T context.Background(), cs, nil, 1, 10, "claude-sonnet-4", - tokens, 1, 99.0, "", // totalCost = 99.0 (would be used if ApplyPricing wins) + tokens, 1, 99.0, "", time.Time{}, // totalCost = 99.0 (would be used if ApplyPricing wins) ) require.NotNil(t, result) // Custom rule: 100*0.05 = 5.0 (NOT 99.0 from totalCost) @@ -934,7 +1032,7 @@ func TestApplyAccountStatsCost_UsesUsageLogServiceTier(t *testing.T) { applyAccountStatsCost( context.Background(), usageLog, cs, bs, 1, 10, "gpt-5.6-sol", "gpt-5.6-sol", - UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, + UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{}, ) require.NotNil(t, usageLog.AccountStatsCost) diff --git a/backend/internal/service/billing_context_schedule.go b/backend/internal/service/billing_context_schedule.go index 7773022504..d1f994707c 100644 --- a/backend/internal/service/billing_context_schedule.go +++ b/backend/internal/service/billing_context_schedule.go @@ -19,13 +19,14 @@ const ContextPricingBasisWholeRequest ContextPricingBasis = "whole_request" // ContextPricingTier (MinTokens, MaxTokens] 区间内的有效 per-token 单价(USD)。 // nil 表示该项无价/不计费;MaxTokens 为 nil 表示无上限。 type ContextPricingTier struct { - MinTokens int - MaxTokens *int - Label string - Input *float64 - Output *float64 - CacheWrite *float64 - CacheRead *float64 + MinTokens int + MaxTokens *int + Label string + Input *float64 + Output *float64 + CacheWrite *float64 + CacheWrite1h *float64 + CacheRead *float64 } // TimePricingPeriod 分时倍率时段:配置时区当天 [StartTime, EndTime) 内整单费用乘 Multiplier。 @@ -289,6 +290,10 @@ func probeContextTier(seg contextSegment, resolved *ResolvedPricing, probe func( return tier, err } // 输出价只随上下文所在档变化:固定上下文 c,对输出 token 数做差商(固定部分相减抵消)。 + tier.CacheWrite1h, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{CacheCreationTokens: n, CacheCreation1hTokens: n} }, c, delta, probe) + if err != nil { + return tier, err + } tier.Output, err = probeComponentPrice(func(n int) UsageTokens { return UsageTokens{InputTokens: c, OutputTokens: n} }, 0, contextProbeDelta, probe) if err != nil { return tier, err @@ -298,6 +303,7 @@ func probeContextTier(seg contextSegment, resolved *ResolvedPricing, probe func( tier.Input = contextPricePtr(tier.Input, explicit.input) tier.Output = contextPricePtr(tier.Output, explicit.output) tier.CacheWrite = contextPricePtr(tier.CacheWrite, explicit.cacheWrite) + tier.CacheWrite1h = contextPricePtr(tier.CacheWrite1h, explicit.cacheWrite1h) tier.CacheRead = contextPricePtr(tier.CacheRead, explicit.cacheRead) return tier, nil } @@ -341,7 +347,7 @@ func roundContextPrice(v float64) float64 { } type explicitContextFields struct { - input, output, cacheWrite, cacheRead bool + input, output, cacheWrite, cacheWrite1h, cacheRead bool } // explicitContextPricingFields 判断各项是否被分组卡/渠道定价(含命中区间)显式配置。 @@ -355,11 +361,13 @@ func explicitContextPricingFields(resolved *ResolvedPricing, contextTokens int) out.input = cp.InputPrice != nil out.output = cp.OutputPrice != nil out.cacheWrite = cp.CacheWritePrice != nil + out.cacheWrite1h = cp.CacheWrite1hPrice != nil || cp.CacheWritePrice != nil out.cacheRead = cp.CacheReadPrice != nil if iv := FindMatchingInterval(resolved.Intervals, contextTokens); iv != nil { out.input = out.input || iv.InputPrice != nil out.output = out.output || iv.OutputPrice != nil out.cacheWrite = out.cacheWrite || iv.CacheWritePrice != nil + out.cacheWrite1h = out.cacheWrite1h || iv.CacheWrite1hPrice != nil || iv.CacheWritePrice != nil out.cacheRead = out.cacheRead || iv.CacheReadPrice != nil } return out @@ -393,7 +401,7 @@ func mergeEqualContextTiers(tiers []ContextPricingTier) []ContextPricingTier { func sameContextPrices(a, b ContextPricingTier) bool { return samePricePtr(a.Input, b.Input) && samePricePtr(a.Output, b.Output) && - samePricePtr(a.CacheWrite, b.CacheWrite) && samePricePtr(a.CacheRead, b.CacheRead) + samePricePtr(a.CacheWrite, b.CacheWrite) && samePricePtr(a.CacheWrite1h, b.CacheWrite1h) && samePricePtr(a.CacheRead, b.CacheRead) } func samePricePtr(a, b *float64) bool { diff --git a/backend/internal/service/billing_context_schedule_test.go b/backend/internal/service/billing_context_schedule_test.go index dfe43f1265..fa2c4bd8ff 100644 --- a/backend/internal/service/billing_context_schedule_test.go +++ b/backend/internal/service/billing_context_schedule_test.go @@ -385,6 +385,24 @@ func TestResolveContextPricingSchedule_NilResolver(t *testing.T) { require.Nil(t, sched) } +func TestPlazaSchedulePreservesCacheWrite1h(t *testing.T) { + cp := sonnetChannel(PricingInterval{MinTokens: 272000, CacheWriteMultiplier: testPtrFloat64(2)}) + cp[0].CacheWritePrice = testPtrFloat64(12.5e-6) + cp[0].CacheWrite1hPrice = testPtrFloat64(20e-6) + group := enabledGroup(PlatformAnthropic) + bs, resolver := newScheduleTestEnv(t, scheduleScenario{channel: cp, groupPlatform: PlatformAnthropic}) + sched, err := bs.ResolveContextPricingSchedule(context.Background(), resolver, ContextPricingScheduleInput{Model: "claude-sonnet-4", Group: group, Platform: PlatformAnthropic}) + require.NoError(t, err) + display := plazaPricingFromSchedule(&cp[0], sched) + requirePrice(t, testPtrFloat64(20e-6), display.CacheWrite1hPrice, "base 1h") + require.Len(t, display.Intervals, 2) + requirePrice(t, testPtrFloat64(25e-6), display.Intervals[1].CacheWritePrice, "tier 5m") + requirePrice(t, testPtrFloat64(40e-6), display.Intervals[1].CacheWrite1hPrice, "tier 1h") + a := ContextPricingTier{CacheWrite1h: testPtrFloat64(1)} + b := ContextPricingTier{CacheWrite1h: testPtrFloat64(2)} + require.Len(t, mergeEqualContextTiers([]ContextPricingTier{a, b}), 2) +} + // --- 对账:阶梯表推算的费用必须等于真实计费函数 --- type tokenKind int diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index bc558420a6..f09f606eb7 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -586,7 +586,20 @@ func (s *BillingService) initFallbackPricing() { // Source: https://docs.z.ai/guides/overview/pricing (USD per 1M tokens) // 注意:CacheReadPricePerToken 即"缓存命中"价格,CacheCreationPricePerToken 留空(智谱未公开写入价,按 0 处理)。 // GLM-4.6 与 GLM-4.5 在 z.ai 国际版上定价一致;GLM-4.5 国内按 ¥0.8/¥2,汇率换算后约 $0.112/$0.28,与国际版 $0.6/$2.2 不同,本分支采用国际版 USD 口径与现有 Claude/GPT 一致。 - // GLM-5.2 与 GLM-5.1 在 z.ai 上同价。 + // GLM-5.3 / GLM-5.2 与 GLM-5.1 在 z.ai 上同价。 + // GLM-5.3-Flash 列表价 $0.15/$0.50(2026-09-09 前五折促销,此处按列表价,与其它模型口径一致)。 + s.fallbackPrices["glm-5.3-flash"] = &ModelPricing{ + InputPricePerToken: 0.15e-6, // $0.15 per MTok + OutputPricePerToken: 0.5e-6, // $0.50 per MTok + CacheReadPricePerToken: 0.03e-6, + SupportsCacheBreakdown: false, + } + s.fallbackPrices["glm-5.3"] = &ModelPricing{ + InputPricePerToken: 1.4e-6, // $1.40 per MTok + OutputPricePerToken: 4.4e-6, // $4.40 per MTok + CacheReadPricePerToken: 0.26e-6, + SupportsCacheBreakdown: false, + } s.fallbackPrices["glm-5.2"] = &ModelPricing{ InputPricePerToken: 1.4e-6, // $1.40 per MTok OutputPricePerToken: 4.4e-6, // $4.40 per MTok @@ -924,9 +937,16 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { // 匹配策略:长 key 优先(具体模型 → 系列 / 厂商),未知型号不回退以避免误计价。 // 与 DeepSeek 一样采用"白名单"语义:未在本表命中的国产模型 alias 一律不返回兜底价。 - // 智谱 GLM(z.ai 公开 SKU:glm-5.2 / glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 / glm-4.6 / glm-4.5 等) + // 智谱 GLM(z.ai 公开 SKU:glm-5.3 / glm-5.3-flash / glm-5.2 / glm-5.1 / glm-5 / glm-5-turbo / glm-4.7 / glm-4.6 / glm-4.5 等) // 匹配顺序:先判别最高 tier,再依次降级。 - // 注意:带小数点的型号必须排在裸 "glm-5" 之前,否则会被 strings.Contains 抢走。 + // 注意:带小数点的型号必须排在裸 "glm-5" 之前,否则会被 strings.Contains 抢走; + // glm-5.3-flash 必须排在 glm-5.3 之前(前者包含后者子串)。 + if strings.Contains(modelLower, "glm-5.3-flash") || strings.Contains(modelLower, "glm-5.3flash") { + return s.fallbackPrices["glm-5.3-flash"] + } + if strings.Contains(modelLower, "glm-5.3") { + return s.fallbackPrices["glm-5.3"] + } if strings.Contains(modelLower, "glm-5.2") { return s.fallbackPrices["glm-5.2"] } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 94ee14ca2a..811419e861 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -549,6 +549,20 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { }, // ---- 智谱 GLM(z.ai USD 口径)---- + { + name: "glm 5.3 flagship", + model: "glm-5.3", + expectedInput: 1.4e-6, + expectedOutput: floatPtr(4.4e-6), + expectedCacheRead: floatPtr(0.26e-6), + }, + { + name: "glm 5.3 flash", + model: "glm-5.3-flash", + expectedInput: 0.15e-6, + expectedOutput: floatPtr(0.5e-6), + expectedCacheRead: floatPtr(0.03e-6), + }, { name: "glm 5.2 flagship", model: "glm-5.2", @@ -639,7 +653,21 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { expectedInput: 0.1e-6, expectedOutput: floatPtr(0.1e-6), }, - // 关键:5.1 / 5.2 必须先于 5 匹配(避免被 glm-5 抢走) + // 关键:5.1 / 5.2 / 5.3 必须先于 5 匹配(避免被 glm-5 抢走) + { + name: "glm 5.3-flash vs glm 5.3 ordering (verbatim 5.3-flash)", + model: "glm-5.3-flash", + expectedInput: 0.15e-6, // = glm-5.3-flash 价格(不是 glm-5.3 的 1.4e-6,更不是 glm-5 的 1e-6) + expectedOutput: floatPtr(0.5e-6), + expectedCacheRead: floatPtr(0.03e-6), + }, + { + name: "glm 5.3 vs glm 5 ordering (verbatim 5.3)", + model: "glm-5.3", + expectedInput: 1.4e-6, // = glm-5.3 价格(不是 glm-5 的 1e-6) + expectedOutput: floatPtr(4.4e-6), + expectedCacheRead: floatPtr(0.26e-6), + }, { name: "glm 5.1 vs glm 5 ordering (verbatim 5.1)", model: "glm-5.1", diff --git a/backend/internal/service/gateway_context_management_test.go b/backend/internal/service/gateway_context_management_test.go index 57ae438309..0e48f2cb91 100644 --- a/backend/internal/service/gateway_context_management_test.go +++ b/backend/internal/service/gateway_context_management_test.go @@ -114,6 +114,23 @@ func TestSanitizeAnthropicBodyForBetaTokens_EmptyBody(t *testing.T) { require.Empty(t, out) } +func TestSanitizeAnthropicBodyForBetaTokens_ThinkingBlockBindingKeptWhenBetaPresent(t *testing.T) { + body := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}},"messages":[]}`) + out, changed := sanitizeAnthropicBodyForBetaTokens(body, claude.BetaThinkingBindingControls) + require.False(t, changed) + require.Equal(t, "drop_block", + gjson.GetBytes(out, "thinking.block_binding.prefix_mismatch_behavior").String()) +} + +func TestSanitizeAnthropicBodyForBetaTokens_ThinkingBlockBindingStrippedWhenBetaMissing(t *testing.T) { + body := []byte(`{"model":"claude-fable-5-1","thinking":{"type":"adaptive","display":"summarized","block_binding":{"prefix_mismatch_behavior":"drop_block"}},"messages":[]}`) + out, changed := sanitizeAnthropicBodyForBetaTokens(body, claude.BetaContextManagement) + require.True(t, changed) + require.False(t, gjson.GetBytes(out, "thinking.block_binding").Exists()) + require.Equal(t, "adaptive", gjson.GetBytes(out, "thinking.type").String()) + require.Equal(t, "summarized", gjson.GetBytes(out, "thinking.display").String()) +} + // ★ 关键回归断言:能力维度 sanitize 解决了 "真 CC + haiku" 路径的过度删除问题。 // 真实 Claude Code CLI 2.1.87+ 客户端 header 含 context-management beta; // 即使 model 是 haiku,sanitize 也不应剥离功能字段。 @@ -145,6 +162,8 @@ func TestComputeFinalAnthropicBeta_OAuthMimic_NonHaiku_IncludesContextManagement "OAuth mimic non-haiku 必须注入完整 CC mimicry beta,含 context-management-2025-06-27") require.True(t, anthropicBetaTokensContains(final, claude.BetaOAuth)) require.True(t, anthropicBetaTokensContains(final, claude.BetaClaudeCode)) + require.True(t, anthropicBetaTokensContains(final, claude.BetaThinkingBindingControls), + "OAuth mimic 必须注入 thinking block binding 所需的 beta") } func TestComputeFinalAnthropicBeta_OAuthMimic_Haiku_IncludesFullClaudeCodeBetas(t *testing.T) { diff --git a/backend/internal/service/gateway_record_usage_test.go b/backend/internal/service/gateway_record_usage_test.go index c14bf9c27e..a0d1c0e180 100644 --- a/backend/internal/service/gateway_record_usage_test.go +++ b/backend/internal/service/gateway_record_usage_test.go @@ -194,6 +194,42 @@ func TestGatewayServiceRecordUsage_PreservesRequestedAndUpstreamModels(t *testin require.Equal(t, mappedModel, *usageRepo.lastLog.UpstreamModel) } +func TestGatewayServiceRecordUsage_GeminiFlashThinkingTierUsesCatalogPrice(t *testing.T) { + for _, baseModel := range []string{"gemini-3.7-flash", "gemini-3.8-flash"} { + t.Run(baseModel, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + svc.billingService = NewBillingService(svc.cfg, &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + baseModel: {InputCostPerToken: 0.75e-6, OutputCostPerToken: 3.75e-6, CacheReadInputTokenCost: 0.075e-6}, + }}) + svc.resolver = NewModelPricingResolver(nil, svc.billingService) + group := &Group{ID: 27, Platform: PlatformGemini, RateMultiplier: 0.15} + model := baseModel + "-medium" + + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gemini_thinking_tier", + Model: model, + UpstreamModel: model, + Usage: ClaudeUsage{InputTokens: 8498, OutputTokens: 469, CacheReadInputTokens: 159248}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 501, GroupID: &group.ID, Group: group}, + User: &User{ID: 601}, + Account: &Account{ID: 701, Platform: PlatformGemini, Type: AccountTypeAPIKey}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, model, usageRepo.lastLog.Model) + require.InDelta(t, 0.02007585, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.0030113775, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.0030113775, userRepo.lastAmount, 1e-12) + }) + } +} + func TestGatewayServiceRecordUsage_PreservesChannelMappedUpstreamModel(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} svc := newGatewayRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}) @@ -424,6 +460,69 @@ func TestGatewayServiceRecordUsage_UsesExplicitPricingAtForPeakRate(t *testing.T } } +func TestGatewayServiceRecordUsage_DeepSeekAccountStatsUsesRequestPricingAtAndUpstreamModel(t *testing.T) { + for _, model := range []struct { + name string + offPeakCost float64 + }{ + {"deepseek-v4-flash", 1000*2.2e-7 + 500*6.6e-7 + 1000*7e-9}, + {"deepseek-v4-pro", 1000*6.6e-7 + 500*1.98e-6 + 1000*2.2e-8}, + } { + for _, slot := range []struct { + name string + pricingAt time.Time + multiplier float64 + }{ + {"peak", time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC), 2}, + {"off_peak", time.Date(2026, time.August, 24, 12, 0, 0, 0, time.UTC), 1}, + } { + t.Run(model.name+"/"+slot.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newGatewayRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}) + groupID := int64(905) + svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, groupID, PlatformDeepseek) + svc.resolver = NewModelPricingResolver(svc.channelService, svc.billingService) + alias := "customer-chat" + inputPrice, outputPrice, cachePrice := 1e-6, 2e-6, 1e-7 + group := &Group{ID: groupID, Platform: PlatformDeepseek, RateMultiplier: 0.8, + ModelPricing: []ChannelModelPricing{{ + Models: []string{alias}, BillingMode: BillingModeToken, + InputPrice: &inputPrice, OutputPrice: &outputPrice, CacheReadPrice: &cachePrice, + }}, + } + err := svc.RecordUsage(context.Background(), &RecordUsageInput{ + Result: &ForwardResult{ + RequestID: "gateway_deepseek_account_stats_" + model.name + "_" + slot.name, + Model: alias, UpstreamModel: model.name, + Usage: ClaudeUsage{InputTokens: 1000, OutputTokens: 500, CacheReadInputTokens: 1000}, + }, + APIKey: &APIKey{ID: 805, GroupID: &groupID, Group: group}, + User: &User{ID: 605}, Account: &Account{ID: 705, Platform: PlatformDeepseek}, + PricingAt: slot.pricingAt, + ChannelUsageFields: ChannelUsageFields{OriginalModel: alias, BillingModelSource: BillingModelSourceRequested}, + }) + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + log := usageRepo.lastLog + require.Equal(t, alias, log.RequestedModel) + require.NotNil(t, log.UpstreamModel) + require.Equal(t, model.name, *log.UpstreamModel) + require.WithinDuration(t, time.Now(), log.CreatedAt, time.Minute) + require.False(t, log.CreatedAt.Equal(slot.pricingAt), "request pricing time must differ from record creation") + customerTotal := 1000*inputPrice + 500*outputPrice + 1000*cachePrice + require.InDelta(t, customerTotal, log.TotalCost, 1e-12) + require.InDelta(t, customerTotal*0.8, log.ActualCost, 1e-12) + require.Equal(t, 1, userRepo.deductCalls) + require.InDelta(t, customerTotal*0.8, userRepo.lastAmount, 1e-12) + require.NotNil(t, log.AccountStatsCost) + require.InDelta(t, model.offPeakCost*slot.multiplier, *log.AccountStatsCost, 1e-12, + "account cost must use the upstream model and historical PricingAt") + }) + } + } +} + func TestGatewayServiceRecordUsage_UsageLogWriteErrorDoesNotSkipBilling(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: false, err: MarkUsageLogCreateNotPersisted(context.Canceled)} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/gateway_request.go b/backend/internal/service/gateway_request.go index 9a3ca9fb88..ab12f4ad22 100644 --- a/backend/internal/service/gateway_request.go +++ b/backend/internal/service/gateway_request.go @@ -912,6 +912,12 @@ const anthropicBetaContextManagementToken = "context-management-2025-06-27" // FullClaudeCodeMimicryBetas 覆盖客户端 beta(该列表不含 fallback beta), // 若不 strip,body 字段与 header 不对称 → 所有模型 400 // +// thinking.block_binding 场景: +// - Claude Fable 5.1 的会话前缀绑定控制受 +// `thinking-binding-controls-2026-08-01` beta 保护 +// - 缺 token 时上游拒收: +// "thinking.adaptive.block_binding: Extra inputs are not permitted" +// // 本函数按最终发送的 anthropic-beta header 决定是否保留 body 中的上述字段: // 缺对应 beta token → strip;客户端 header 已带对应 beta → 保留(不过度删除)。 // 这将限制完全建立在 "能力维度" 上,与 model 名 / token type / mimicry 子路径无关。 @@ -935,6 +941,13 @@ func sanitizeAnthropicBodyForBetaTokens(body []byte, anthropicBetaHeader string) body, changed = b, true } + // thinking.block_binding:需要 thinking-binding-controls beta。 + if b, deleted := stripAnthropicBodyFieldUnlessBeta( + body, "thinking.block_binding", anthropicBetaHeader, claude.BetaThinkingBindingControls, + ); deleted { + body, changed = b, true + } + // fallbacks:server-side refusal fallback,仅接受 server-side-fallback beta。 if b, deleted := stripAnthropicBodyFieldUnlessBeta( body, "fallbacks", anthropicBetaHeader, claude.BetaServerSideFallback, diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 54ebcfde18..97e35fdb1b 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -1452,6 +1452,10 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, } sort.Strings(models) + if platform == PlatformOpenAI { + models = supplementUnmappedOpenAIModels(accounts, models) + } + if s.modelsListCache != nil { s.modelsListCache.Set(cacheKey, cloneStringSlice(models), s.modelsListCacheTTL) modelsListCacheStoreTotal.Add(1) diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go index 0cc8467523..1e1a0f8477 100644 --- a/backend/internal/service/gateway_usage_billing.go +++ b/backend/internal/service/gateway_usage_billing.go @@ -831,7 +831,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage CacheReadTokens: result.Usage.CacheReadInputTokens, ImageOutputTokens: result.Usage.ImageOutputTokens, }, - cost.TotalCost, + cost.TotalCost, pricingAt, ) } diff --git a/backend/internal/service/group_model_allowlist.go b/backend/internal/service/group_model_allowlist.go index 2ce1158947..6ef6b41a85 100644 --- a/backend/internal/service/group_model_allowlist.go +++ b/backend/internal/service/group_model_allowlist.go @@ -2,11 +2,13 @@ package service import ( "net/http" + "slices" "strings" "github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) // GroupModelAllowlist 是 service 层的分组模型白名单(与 domain.GroupModelAllowlist @@ -26,6 +28,22 @@ func GroupModelAllowlistFromDomain(cfg domain.GroupModelAllowlist) GroupModelAll return GroupModelAllowlist{Enabled: cfg.Enabled, Models: cfg.Models} } +// supplementUnmappedOpenAIModels ensures a partial mapping catalog does not +// hide models from unmapped OpenAI accounts. An empty catalog is left unchanged +// so callers retain their existing discovery fallback. +func supplementUnmappedOpenAIModels(accounts []Account, models []string) []string { + if len(models) == 0 { + return models + } + for i := range accounts { + account := &accounts[i] + if account.Platform == PlatformOpenAI && len(account.GetModelMapping()) == 0 { + return dedupeAndSortModelIDs(slices.Concat(models, openai.DefaultModelIDs())) + } + } + return models +} + // normalizeGroupModelAllowlist 归一化管理端提交的分组模型白名单: // 条目 TrimSpace、按小写去重保序;`*` 只允许出现在条目末尾; // enabled=true 且列表为空视为配置错误,返回 400 而不是运行时静默放行/拒绝。 diff --git a/backend/internal/service/model_plaza_service.go b/backend/internal/service/model_plaza_service.go index 6b2a87b263..b1618acf78 100644 --- a/backend/internal/service/model_plaza_service.go +++ b/backend/internal/service/model_plaza_service.go @@ -269,6 +269,7 @@ func plazaPricingFromSchedule(raw *ChannelModelPricing, sched *ContextPricingSch out.InputPrice = first.Input out.OutputPrice = first.Output out.CacheWritePrice = first.CacheWrite + out.CacheWrite1hPrice = first.CacheWrite1h out.CacheReadPrice = first.CacheRead if len(sched.Tiers) > 1 { out.Intervals = plazaIntervalsFromTiers(sched.Tiers) @@ -280,14 +281,15 @@ func plazaIntervalsFromTiers(tiers []ContextPricingTier) []PricingInterval { intervals := make([]PricingInterval, 0, len(tiers)) for i, t := range tiers { intervals = append(intervals, PricingInterval{ - MinTokens: t.MinTokens, - MaxTokens: t.MaxTokens, - TierLabel: t.Label, - InputPrice: t.Input, - OutputPrice: t.Output, - CacheWritePrice: t.CacheWrite, - CacheReadPrice: t.CacheRead, - SortOrder: i, + MinTokens: t.MinTokens, + MaxTokens: t.MaxTokens, + TierLabel: t.Label, + InputPrice: t.Input, + OutputPrice: t.Output, + CacheWritePrice: t.CacheWrite, + CacheWrite1hPrice: t.CacheWrite1h, + CacheReadPrice: t.CacheRead, + SortOrder: i, }) } return intervals diff --git a/backend/internal/service/openai_capacity_shed_test.go b/backend/internal/service/openai_capacity_shed_test.go index ac32fa5749..537ec435c5 100644 --- a/backend/internal/service/openai_capacity_shed_test.go +++ b/backend/internal/service/openai_capacity_shed_test.go @@ -298,6 +298,59 @@ func TestOpenAIStreamCapacityShedAfterOutputRewritesCodeForClient(t *testing.T) require.True(t, logSink.ContainsFieldValue("upstream_request_id", "rid-shed-after-output")) } +func TestOpenAIStreamProcessingFailureAfterOutputIsRecorded(t *testing.T) { + gin.SetMode(gin.TestMode) + stream := strings.Join([]string{ + "event: response.created", + `data: {"type":"response.created","response":{"id":"resp_processing_failure"}}`, + "", + "event: response.output_text.delta", + `data: {"type":"response.output_text.delta","delta":"partial"}`, + "", + "event: response.failed", + `data: {"type":"response.failed","response":{"id":"resp_processing_failure","status":"failed","error":{"code":"server_error","message":"An error occurred while processing your request. Please include the request ID rid-processing-failure in your message."}}}`, + "", + }, "\n") + + for _, passthrough := range []bool{false, true} { + t.Run(map[bool]string{false: "native", true: "passthrough"}[passthrough], func(t *testing.T) { + svc := &OpenAIGatewayService{cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}} + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(stream)), + Header: http.Header{"X-Request-Id": []string{"rid-processing-failure"}}, + } + account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Name: "codex-account"} + + var err error + if passthrough { + _, err = svc.handleStreamingResponsePassthrough(c.Request.Context(), resp, c, account, time.Now(), "gpt-5.6-terra", "gpt-5.6-terra") + } else { + _, err = svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "gpt-5.6-terra", "gpt-5.6-terra") + } + + require.Error(t, err) + var failoverErr *UpstreamFailoverError + require.False(t, errors.As(err, &failoverErr)) + require.NotEmpty(t, rec.Body.String()) + require.Contains(t, rec.Body.String(), "response.failed") + require.NotNil(t, c) + rawEvents, ok := c.Get(OpsUpstreamErrorsKey) + require.True(t, ok) + events, ok := rawEvents.([]*OpsUpstreamErrorEvent) + require.True(t, ok) + require.NotEmpty(t, events) + event := events[len(events)-1] + require.Equal(t, "stream_failed", event.Kind) + require.Equal(t, "rid-processing-failure", event.UpstreamRequestID) + require.Contains(t, event.Message, "An error occurred while processing your request") + }) + } +} + // helper 单测:只有降载码被改写,其余错误码(尤其 rate_limit_exceeded,客户端 // 依赖其原码解析重试延时)必须原样保留。 func TestSanitizeOpenAICapacityShedErrorCodeForClient(t *testing.T) { diff --git a/backend/internal/service/openai_codex_astra_image_test.go b/backend/internal/service/openai_codex_astra_image_test.go new file mode 100644 index 0000000000..ec4cd3eeea --- /dev/null +++ b/backend/internal/service/openai_codex_astra_image_test.go @@ -0,0 +1,72 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildCodexModelsManifestForGroupCorrectsOfficialAstraStaleModalities(t *testing.T) { + t.Parallel() + + const groupID int64 = 780 + account := Account{ + ID: 1, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test"}, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{ + Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {ID: "gpt-6-astra", InputModalities: []string{"text"}}, + }, + }) + + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{groupID: {account}}, + }} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + []string{"gpt-6-astra"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"]) +} + +func TestBuildCodexModelsManifestForGroupPreservesCompatibleAstraTextOnlyMetadata(t *testing.T) { + t.Parallel() + + const groupID int64 = 781 + account := Account{ + ID: 2, + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://relay.example.test/v1"}, + } + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{ + Models: map[string]UpstreamModelMetadata{ + "gpt-6-astra": {ID: "gpt-6-astra", InputModalities: []string{"text"}}, + }, + }) + + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{ + byGroup: map[int64][]Account{groupID: {account}}, + }} + + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), + &Group{ID: groupID, Platform: PlatformOpenAI}, + "", + []string{"gpt-6-astra"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + require.Equal(t, []any{"text"}, models[0]["input_modalities"]) +} diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go index 63a819e1e0..445b3b7899 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -9,6 +9,7 @@ import ( var codexToolCapabilityFields = []string{ "supports_search_tool", "apply_patch_tool_type", "comp_hash", "tool_mode", "use_responses_lite", + "multi_agent_reasoning_effort", "multi_agent_version", } func applyCodexToolCapabilities(dst, src map[string]json.RawMessage, overwrite bool) bool { @@ -18,7 +19,7 @@ func applyCodexToolCapabilities(dst, src map[string]json.RawMessage, overwrite b if len(value) == 0 { continue } - // These five Codex fields are nullable booleans or strings, never arbitrary objects. + // These Codex fields are nullable booleans or strings, never arbitrary objects. if !bytes.Equal(value, []byte("null")) { if field == "supports_search_tool" || field == "use_responses_lite" { if !bytes.Equal(value, []byte("true")) && !bytes.Equal(value, []byte("false")) { @@ -422,6 +423,8 @@ func configuredCodexReasoningLevelDescription(level string) string { return "Extra-high reasoning depth for difficult tasks" case "max": return "Maximum reasoning depth for complex tasks" + case "ultra": + return "Maximum reasoning with automatic task delegation" default: return "Reasoning effort supported by the upstream model" } diff --git a/backend/internal/service/openai_codex_model_metadata_test.go b/backend/internal/service/openai_codex_model_metadata_test.go index 616ff88606..cb757a1faa 100644 --- a/backend/internal/service/openai_codex_model_metadata_test.go +++ b/backend/internal/service/openai_codex_model_metadata_test.go @@ -9,6 +9,58 @@ import ( "github.com/stretchr/testify/require" ) +func TestAstraUltraCatalogPreservesWorkflowMetadata(t *testing.T) { + // Ultra is a Codex workflow. Its inference effort must survive catalog sync, + // aliases and group generation instead of silently falling back to max. + _, metadata, err := extractUpstreamModelCatalog([]byte(`{"models":[{ + "slug":"gpt-6-astra","supported_reasoning_levels":[{"effort":"high"},{"effort":"ultra"}], + "multi_agent_reasoning_effort":"high","multi_agent_version":"v2" + }]}`), false) + require.NoError(t, err) + account := Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ + "base_url": "https://relay.example/v1", "model_mapping": map[string]any{"public-astra": "gpt-6-astra"}, + }} + account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: metadata}) + body, err := buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, []Account{account}, nil, nil, true) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + require.Equal(t, "high", model["multi_agent_reasoning_effort"]) + require.Equal(t, "v2", model["multi_agent_version"]) + require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, model)) + + peer := Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: account.Credentials} + body, err = buildCodexModelsManifestForAccounts(PlatformOpenAI, []string{"public-astra"}, []Account{account, peer}, nil, nil, true) + require.NoError(t, err) + model = decodeCodexManifestModels(t, body)[0] + require.Nil(t, model["multi_agent_reasoning_effort"], "do not advertise one account's override for all peers") + require.Nil(t, model["multi_agent_version"]) +} + +func TestAstraUltraCatalogPreservesExplicitWorkflowOverrides(t *testing.T) { + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, + Credentials: map[string]any{"base_url": "https://relay.example/v1"}} + for _, fields := range []string{ + `"multi_agent_reasoning_effort":"high","multi_agent_version":"v1"`, + `"multi_agent_reasoning_effort":null,"multi_agent_version":null`, + } { + for _, source := range []string{ + `{"models":[{"slug":"gpt-6-astra",` + fields + `}]}`, + `{"data":[{"id":"gpt-6-astra",` + fields + `}]}`, + } { + converted := convertOpenAIModelListToCodexManifestForAccount([]byte(source), account) + body, err := completeAPIKeyCodexModelsManifestMetadata(converted, true, account) + require.NoError(t, err) + model := decodeCodexManifestModels(t, body)[0] + var expected map[string]any + require.NoError(t, json.Unmarshal([]byte(`{`+fields+`}`), &expected)) + for key, value := range expected { + require.Contains(t, model, key) + require.Equal(t, value, model[key]) + } + } + } +} + func TestAstraCodexToolCapabilitiesUseAccountScopeAndSharedDeclarations(t *testing.T) { newAccount := func(baseURL string) Account { return Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ @@ -645,7 +697,8 @@ func TestCodexAliasFailoverMappingHonorsModelRouting(t *testing.T) { levels, def, modalities := manifestFieldsOf(t, nil) require.Empty(t, levels, "ambiguous alias target must not advertise reasoning levels") require.Nil(t, def) - require.Equal(t, []any{"text"}, modalities) + require.Equal(t, []any{"text", "image"}, modalities, + "image input is resolved per-account independently of routing rules") }) t.Run("routing rule resolves the alias", func(t *testing.T) { @@ -657,7 +710,7 @@ func TestCodexAliasFailoverMappingHonorsModelRouting(t *testing.T) { } levels, def, modalities := manifestFieldsOf(t, group) require.Equal(t, - []string{"low", "medium", "high", "xhigh", "max"}, + []string{"low", "medium", "high", "xhigh", "max", "ultra"}, levels, "routed alias must keep a movable reasoning slider", ) diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index f1a59bbca2..c5d3f8b331 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -116,8 +116,8 @@ type OpenAIModelsResponse struct { NotModified bool } -// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog exclusively -// from the public model names configured on accounts in an OpenAI group. The +// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog from configured +// public model names, supplemented by defaults for unmapped OpenAI accounts. The // boolean result distinguishes "no explicit configuration" from a configured // catalog that becomes empty after group-level filtering. func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest( @@ -295,7 +295,7 @@ func openAIConfiguredCodexModelIDs(accounts []Account) []string { } func openAIConfiguredCodexModelIDsForGroup(accounts []Account, group *Group) []string { - models := openAIConfiguredCodexModelIDs(accounts) + models := supplementUnmappedOpenAIModels(accounts, openAIConfiguredCodexModelIDs(accounts)) if group == nil || !group.ModelAllowlistEnabled() { return models } @@ -379,6 +379,7 @@ type configuredCodexModelDescriptor struct { Description string `json:"description"` DefaultReasoningLevel *string `json:"default_reasoning_level,omitempty"` SupportedReasoningLevels []configuredCodexReasoningLevel `json:"supported_reasoning_levels"` + MultiAgentReasoningEffort *string `json:"multi_agent_reasoning_effort,omitempty"` ShellType string `json:"shell_type"` Visibility string `json:"visibility"` SupportedInAPI bool `json:"supported_in_api"` @@ -510,6 +511,11 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr descriptor.MaxContextWindow = configuredCodexGPT56MaxContext } if isOpenAIGPT6AstraModel(modelID) { + // Codex resolves the Ultra workflow to this effort before inference. + // openai/codex a9896da3: codex-rs/models-manager/models.json. + multiAgentEffort := "xhigh" + descriptor.MultiAgentReasoningEffort = &multiAgentEffort + descriptor.MultiAgentVersion = "v2" descriptor.ContextWindow = configuredCodexGPT6AstraContext descriptor.MaxContextWindow = configuredCodexGPT6AstraContext } @@ -620,7 +626,7 @@ func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasonin Description: "Maximum reasoning depth for complex tasks", }) } - if normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" { + if isOpenAIGPT6AstraModel(modelID) || normalized == "gpt-5.6-sol" || normalized == "gpt-5.6-terra" { levels = append(levels, configuredCodexReasoningLevel{ Effort: "ultra", Description: "Maximum reasoning with automatic task delegation", @@ -909,13 +915,15 @@ func buildCodexModelsManifest( seen[modelID] = struct{}{} descriptor := newConfiguredCodexModelDescriptor(metadataModelID) descriptor.Slug = modelID - if imageInputModels[modelID] { - descriptor.InputModalities = []string{"text", "image"} - } descriptor.SupportsSearchTool = searchToolModels[modelID] if metadata, ok := modelMetadata[modelID]; ok { applyUpstreamModelMetadataToCodexDescriptor(&descriptor, metadata) } + if imageInputModels[modelID] { + // Apply the capability-derived modality after upstream metadata so + // a stale official Astra snapshot cannot downgrade the catalog. + descriptor.InputModalities = []string{"text", "image"} + } if metadataModelID != modelID { descriptor.DisplayName = modelID descriptor.Description = configuredCodexCustomDescription @@ -1207,6 +1215,13 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) case PlatformOpenAI: if metadata, ok := account.GetUpstreamModelMetadata(upstreamModel); ok { if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 { + // Official GPT-6 Astra metadata briefly shipped with a stale + // text-only modality list. Keep explicit provider metadata + // authoritative for compatible hosts, but repair that stale + // official snapshot at the capability boundary. + if isOpenAIGPT6AstraModel(upstreamModel) && isOfficialOpenAICodexAccount(account) { + return true + } return stringSliceContains(modalities, "image") } } @@ -1233,6 +1248,16 @@ func accountCodexModelSupportsImageInput(account *Account, upstreamModel string) } } +func isOfficialOpenAICodexAccount(account *Account) bool { + if account == nil || account.Platform != PlatformOpenAI { + return false + } + if account.IsOpenAIOAuth() { + return true + } + return account.IsOpenAIApiKey() && isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL()) +} + func isGrokCodexImageInputModel(model string) bool { switch strings.ToLower(strings.TrimSpace(model)) { case "grok-4.3", diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index c215e077ef..a58634b749 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -351,10 +351,13 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t gpt6Astra := newConfiguredCodexModelDescriptor("gpt-6-astra") require.Equal(t, "GPT-6 Astra", gpt6Astra.DisplayName) + require.True(t, strings.HasPrefix(strings.TrimSpace(gpt6Astra.ModelMessages.InstructionsTemplate), "You are Codex, an agent based on GPT-6.")) require.NotNil(t, gpt6Astra.DefaultReasoningLevel) require.Equal(t, "medium", *gpt6Astra.DefaultReasoningLevel) - require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels)) - require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "ultra"}) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels)) + require.NotNil(t, gpt6Astra.MultiAgentReasoningEffort) + require.Equal(t, "xhigh", *gpt6Astra.MultiAgentReasoningEffort) + require.Equal(t, "v2", gpt6Astra.MultiAgentVersion) require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"}) require.True(t, configuredCodexSupportsPriorityServiceTier("gpt-6-astra")) require.Equal(t, []configuredCodexServiceTier{{ @@ -370,7 +373,10 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.Equal(t, int64(1_050_000), gpt6Astra.MaxContextWindow) gpt6 := newConfiguredCodexModelDescriptor("gpt-6") require.Equal(t, "GPT-6 (Astra)", gpt6.DisplayName) - require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt6.SupportedReasoningLevels)) + require.True(t, strings.HasPrefix(strings.TrimSpace(gpt6.ModelMessages.InstructionsTemplate), "You are Codex, an agent based on GPT-6.")) + require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6.SupportedReasoningLevels)) + require.NotNil(t, gpt6.MultiAgentReasoningEffort) + require.Equal(t, "xhigh", *gpt6.MultiAgentReasoningEffort) require.Equal(t, int64(1_050_000), gpt6.ContextWindow) gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5") @@ -409,6 +415,25 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t require.Equal(t, configuredCodexTruncationPolicy{Mode: "bytes", Limit: 10_000}, custom.TruncationPolicy) } +func TestBuildCodexModelsManifestUsesGPT6AstraInstructions(t *testing.T) { + body, err := BuildCodexModelsManifest([]string{"gpt-6-astra"}) + require.NoError(t, err) + + var manifest struct { + Models []struct { + ModelMessages struct { + InstructionsTemplate string `json:"instructions_template"` + } `json:"model_messages"` + } `json:"models"` + } + require.NoError(t, json.Unmarshal(body, &manifest)) + require.Len(t, manifest.Models, 1) + require.True(t, strings.HasPrefix( + strings.TrimSpace(manifest.Models[0].ModelMessages.InstructionsTemplate), + "You are Codex, an agent based on GPT-6.", + )) +} + func effortsFromConfiguredCodexLevels(levels []configuredCodexReasoningLevel) []string { efforts := make([]string, 0, len(levels)) for _, level := range levels { @@ -1105,7 +1130,7 @@ func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag) } -// Scenario: OpenAI 分组存在账号模型配置时直接生成本地 Codex 清单。 +// Mixed groups retain configured metadata alongside defaults for unmapped accounts. func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t *testing.T) { t.Parallel() @@ -1150,8 +1175,10 @@ func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t require.NoError(t, err) require.True(t, configured) models := decodeCodexManifestModels(t, manifest.Body) - require.Len(t, models, 1) require.Equal(t, "glm-5.3", models[0]["slug"]) + require.Contains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-5.6-sol") + require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-image-2") + require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "codex-auto-review") require.Equal(t, "GLM 5.3", models[0]["display_name"]) require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0])) require.Equal(t, "medium", models[0]["default_reasoning_level"]) diff --git a/backend/internal/service/openai_codex_tool_names.go b/backend/internal/service/openai_codex_tool_names.go index dfa8deacc0..849a45e417 100644 --- a/backend/internal/service/openai_codex_tool_names.go +++ b/backend/internal/service/openai_codex_tool_names.go @@ -115,6 +115,9 @@ func collectOpenAIResponsesToolNameFields(reqBody map[string]any) []codexToolNam } } if choice, ok := reqBody["tool_choice"].(map[string]any); ok { + if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(choice["type"])), "allowed_tools") { + collectTools(choice["tools"]) + } if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(choice["type"])), "function") { appendName(choice, "name") if function, ok := choice["function"].(map[string]any); ok { diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index ef722a93c1..a67d626a23 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -357,6 +357,12 @@ func normalizeCodexToolChoice(reqBody map[string]any) bool { if choiceType == "" { return false } + if choiceType == "allowed_tools" { + // This is a selection policy, not a declared tool type. Preserve it for + // upstream validation (including references to input.additional_tools); + // falling back to auto would silently discard the caller's restriction. + return false + } modified := false if choiceType == "function" { name := strings.TrimSpace(firstNonEmptyString(choiceMap["name"])) @@ -1392,8 +1398,7 @@ func extractPromptLikeInstructionsFromInput(reqBody map[string]any) string { // defaultCodexSynthInstructions 返回合成路径在 instructions 为空时应填入的默认提示词。 // -// 按 model 选择真实 Codex CLI 的 base instructions(codex 系→GPT-5-Codex, -// gpt-5.2→GPT-5.2,gpt-5.1/gpt-5→GPT-5.1),使合成请求在提示词层面贴近真实 Codex 行为; +// 按 model 选择真实 Codex CLI 的 base instructions,使合成请求在提示词层面贴近真实 Codex 行为; // 若内嵌 prompt 意外为空,回退到最小占位符以保证字段非空。 func defaultCodexSynthInstructions(model string) string { if instructions := strings.TrimSpace(openai.CodexBaseInstructionsForModel(model)); instructions != "" { diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 899ba7d933..6a682ce73e 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "fmt" "strings" "testing" @@ -477,6 +478,74 @@ func TestApplyCodexOAuthTransform_StringifiesNonStringMessageContentText(t *test require.Equal(t, `["a","b"]`, part["text"]) } +func TestApplyCodexOAuthTransform_PreservesAllowedTools(t *testing.T) { + for _, placement := range []string{"top_level", "additional_tools"} { + for _, mode := range []string{"auto", "required"} { + t.Run(placement+"/"+mode, func(t *testing.T) { + decision := map[string]any{"type": "function", "name": "ProbeAccept"} + choice := map[string]any{ + "type": "allowed_tools", "mode": mode, + "tools": []any{map[string]any{"type": "function", "name": "ProbeAccept"}}, + } + tools := []any{ + map[string]any{"type": "function", "name": "ProbeBase"}, + map[string]any{"type": "web_search"}, + map[string]any{"type": "image_generation"}, + } + input := []any{map[string]any{"type": "message", "role": "user", "content": "probe"}} + if placement == "top_level" { + tools = append(tools, decision) + } else { + input = append(input, map[string]any{ + "type": "additional_tools", "role": "developer", "tools": []any{decision}, + }) + } + reqBody := map[string]any{"tools": tools, "input": input, "tool_choice": choice} + before, err := json.Marshal(reqBody) + require.NoError(t, err) + reqBody["model"] = "gpt-6-astra" + result := applyCodexOAuthTransform(reqBody, true, false) + require.NoError(t, result.Error) + after, err := json.Marshal(map[string]any{ + "tools": reqBody["tools"], "input": reqBody["input"], "tool_choice": reqBody["tool_choice"], + }) + require.NoError(t, err) + require.JSONEq(t, string(before), string(after)) + }) + } + } +} + +func TestNormalizeCodexToolChoice_InvalidAllowedToolsNeverBecomesAuto(t *testing.T) { + for _, choice := range []map[string]any{ + {"type": "allowed_tools"}, + {"type": "allowed_tools", "mode": "invalid", "tools": []any{}}, + {"type": "allowed_tools", "mode": "required", "tools": "invalid"}, + {"type": "allowed_tools", "mode": "required", "tools": []any{map[string]any{"type": "function", "name": "missing"}}}, + } { + reqBody := map[string]any{"tool_choice": choice} + require.False(t, normalizeCodexToolChoice(reqBody)) + // The upstream owns schema validation. A malformed restriction must never + // silently become permission to call every supplied tool. + require.Equal(t, choice, reqBody["tool_choice"]) + } +} + +func TestApplyCodexOAuthTransform_AllowedToolsKeepsReservedNameReferences(t *testing.T) { + declaration := map[string]any{"type": "function", "name": "python"} + reference := map[string]any{"type": "function", "name": "python"} + choice := map[string]any{"type": "allowed_tools", "mode": "required", "tools": []any{reference}} + reqBody := map[string]any{ + "model": "gpt-6-astra", "tools": []any{declaration}, "tool_choice": choice, + } + result := applyCodexOAuthTransform(reqBody, true, false) + require.NoError(t, result.Error) + require.Equal(t, choice, reqBody["tool_choice"]) + require.Equal(t, codexPythonToolAlias, declaration["name"]) + require.Equal(t, codexPythonToolAlias, reference["name"]) + require.Equal(t, "python", result.ToolNameReverse[codexPythonToolAlias]) +} + func TestApplyCodexOAuthTransform_DowngradesUnknownToolChoice(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.4", @@ -1497,6 +1566,20 @@ func TestApplyCodexOAuthTransform_GPT55SuppliesModelSpecificInstructions(t *test require.True(t, result.Modified) } +func TestApplyCodexOAuthTransform_GPT6AstraSuppliesModelSpecificInstructions(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-6-astra", + } + + result := applyCodexOAuthTransform(reqBody, true, false) + + instructions, ok := reqBody["instructions"].(string) + require.True(t, ok) + require.True(t, strings.HasPrefix(strings.TrimSpace(instructions), "You are Codex, an agent based on GPT-6.")) + require.NotContains(t, instructions, "You are Codex, a coding agent based on GPT-5.") + require.True(t, result.Modified) +} + func TestApplyCodexOAuthTransform_NonCodexCLI_PreservesExistingInstructions(t *testing.T) { // 非 Codex CLI 场景:已有 instructions 时保留客户端的值,不再覆盖 diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index d389c6c7f8..56673c1c23 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -358,12 +358,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco return nil, errors.New("image generation disabled for group") } - instructions := gjson.GetBytes(body, "instructions") - instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeCNResponses { - markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) - } - isCompactRequest := compactPath requestedModel := reqModel billingModel, upstreamModel := resolveOpenAIForwardMappedModels(account, requestedModel, isCompactRequest) @@ -372,6 +366,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco upstreamModel = compactModel } } + instructions := gjson.GetBytes(body, "instructions") + instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" + if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeCNResponses { + markPatchSet("instructions", defaultCodexSynthInstructions(upstreamModel)) + } if billingModel != requestedModel { logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Model mapping applied: %s -> %s (account: %s, isCodexCLI: %v)", requestedModel, billingModel, account.Name, isCodexCLI) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index e755548a7d..7f3144752d 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -272,10 +272,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if account.Platform == PlatformOpenAI { policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, responsesBody) if policyErr != nil { - var overLimit *ReasoningEffortOverLimitError - if errors.As(policyErr, &overLimit) { + if IsReasoningEffortPolicyDenied(policyErr) { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) - writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error()) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) } return nil, policyErr } diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 270762a0dc..928e3fa982 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -3,7 +3,6 @@ package service import ( "context" "encoding/json" - "errors" "fmt" "net/http" "strings" @@ -83,10 +82,9 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( if account.Platform == PlatformOpenAI { policyBody, changed, policyErr := ApplyOpenAIReasoningEffortPolicyFromContext(ctx, chatBody) if policyErr != nil { - var overLimit *ReasoningEffortOverLimitError - if errors.As(policyErr, &overLimit) { + if IsReasoningEffortPolicyDenied(policyErr) { MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied) - writeAnthropicError(c, http.StatusForbidden, "forbidden_error", overLimit.Error()) + writeAnthropicError(c, http.StatusForbidden, "forbidden_error", policyErr.Error()) } return nil, policyErr } diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 7a949209b9..dfebb47a50 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -2079,6 +2079,11 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header, mappedModel) bareErrorAccountSideEffectsPending = false } + if eventType == "response.failed" { + // The stream cannot be replayed after semantic output. Preserve the + // terminal event, while making the upstream failure queryable. + s.recordOpenAIStreamUpstreamError(c, account, true, upstreamRequestID, "stream_failed", dataBytes, failedMessage) + } } if !outputStarted { shouldFailover := false diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index b3f4153784..e5dca2d7b7 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -569,6 +569,75 @@ func TestOpenAIGatewayServiceRecordUsage_TimePricingUsesExplicitPricingAt(t *tes require.InDelta(t, baseCost*0.8, usageRepo.lastLog.ActualCost, 1e-12) require.InDelta(t, 0.8, usageRepo.lastLog.RateMultiplier, 1e-12) } + +func TestOpenAIGatewayServiceRecordUsage_DeepSeekAccountStatsUsesRequestPricingAtAndUpstreamModel(t *testing.T) { + for _, model := range []struct { + name string + offPeakCost float64 + }{ + {"deepseek-v4-flash", 1000*2.2e-7 + 500*6.6e-7 + 1000*7e-9}, + {"deepseek-v4-pro", 1000*6.6e-7 + 500*1.98e-6 + 1000*2.2e-8}, + } { + for _, slot := range []struct { + name string + pricingAt time.Time + multiplier float64 + }{ + {"peak", time.Date(2026, time.August, 24, 2, 0, 0, 0, time.UTC), 2}, + {"off_peak", time.Date(2026, time.August, 24, 12, 0, 0, 0, time.UTC), 1}, + } { + t.Run(model.name+"/"+slot.name, func(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, &openAIRecordUsageSubRepoStub{}, nil) + groupID := int64(18) + cache := newEmptyChannelCache() + cache.channelByGroupID[groupID] = &Channel{ID: 1, Status: StatusActive} + cache.groupPlatform[groupID] = PlatformDeepseek + cache.loadedAt = time.Now() + svc.channelService = &ChannelService{} + svc.channelService.cache.Store(cache) + svc.resolver = NewModelPricingResolver(svc.channelService, svc.billingService) + alias := "customer-chat" + inputPrice, outputPrice, cachePrice := 1e-6, 2e-6, 1e-7 + group := &Group{ID: groupID, Platform: PlatformDeepseek, RateMultiplier: 0.8, + ModelPricing: []ChannelModelPricing{{ + Models: []string{alias}, BillingMode: BillingModeToken, + InputPrice: &inputPrice, OutputPrice: &outputPrice, CacheReadPrice: &cachePrice, + }}, + } + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "openai_deepseek_account_stats_" + model.name + "_" + slot.name, + Model: alias, BillingModel: alias, UpstreamModel: model.name, + Usage: OpenAIUsage{InputTokens: 2000, OutputTokens: 500, CacheReadInputTokens: 1000}, + }, + APIKey: &APIKey{ID: 1008, GroupID: &groupID, Group: group}, + User: &User{ID: 2008}, Account: &Account{ID: 3008, Platform: PlatformDeepseek}, + PricingAt: slot.pricingAt, + ChannelUsageFields: ChannelUsageFields{OriginalModel: alias, BillingModelSource: BillingModelSourceRequested}, + }) + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + log := usageRepo.lastLog + require.Equal(t, alias, log.RequestedModel) + require.NotNil(t, log.UpstreamModel) + require.Equal(t, model.name, *log.UpstreamModel) + require.WithinDuration(t, time.Now(), log.CreatedAt, time.Minute) + require.False(t, log.CreatedAt.Equal(slot.pricingAt), "request pricing time must differ from record creation") + customerTotal := 1000*inputPrice + 500*outputPrice + 1000*cachePrice + require.InDelta(t, customerTotal, log.TotalCost, 1e-12) + require.InDelta(t, customerTotal*0.8, log.ActualCost, 1e-12) + require.Equal(t, 1, userRepo.deductCalls) + require.InDelta(t, customerTotal*0.8, userRepo.lastAmount, 1e-12) + require.NotNil(t, log.AccountStatsCost) + require.InDelta(t, model.offPeakCost*slot.multiplier, *log.AccountStatsCost, 1e-12, + "account cost must use the upstream model and historical PricingAt") + }) + } + } +} + func TestOpenAIGatewayServiceRecordUsage_IncludesEndpointMetadata(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} diff --git a/backend/internal/service/openai_gateway_response_handling.go b/backend/internal/service/openai_gateway_response_handling.go index 4ee9dca2b1..d5b35e8585 100644 --- a/backend/internal/service/openai_gateway_response_handling.go +++ b/backend/internal/service/openai_gateway_response_handling.go @@ -561,6 +561,12 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context. s.handleOpenAIStreamTerminalAccountSideEffects(c, account, dataBytes, failedMessage, resp.Header, mappedModel) bareErrorAccountSideEffectsPending = false } + if eventType == "response.failed" { + // Once semantic output is committed, failover replay is unsafe. Keep + // the terminal event on the existing stream, but retain the upstream + // request ID and payload for operations diagnostics. + s.recordOpenAIStreamUpstreamError(c, account, false, upstreamRequestID, "stream_failed", dataBytes, failedMessage) + } } if !outputStarted { shouldFailover := false diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 79a7999799..4570ed1631 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -472,7 +472,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if apiKey.GroupID != nil { applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService, account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model, - tokens, cost.TotalCost, + tokens, cost.TotalCost, pricingAt, ) } diff --git a/backend/internal/service/openai_model_alias.go b/backend/internal/service/openai_model_alias.go index 147ed20941..f34a28abac 100644 --- a/backend/internal/service/openai_model_alias.go +++ b/backend/internal/service/openai_model_alias.go @@ -1,6 +1,10 @@ package service -import "strings" +import ( + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" +) func lastOpenAIModelSegment(model string) string { model = strings.TrimSpace(model) @@ -15,38 +19,7 @@ func lastOpenAIModelSegment(model string) string { } func canonicalizeOpenAIModelAliasSpelling(model string) string { - model = strings.ToLower(lastOpenAIModelSegment(model)) - if model == "" { - return "" - } - - normalized := strings.ReplaceAll(model, "_", "-") - normalized = strings.Join(strings.Fields(normalized), "-") - for strings.Contains(normalized, "--") { - normalized = strings.ReplaceAll(normalized, "--", "-") - } - - if strings.HasPrefix(normalized, "gpt5") { - normalized = "gpt-5" + strings.TrimPrefix(normalized, "gpt5") - } - if !strings.HasPrefix(normalized, "gpt-") && !strings.Contains(normalized, "codex") { - return "" - } - - replacements := []struct { - from string - to string - }{ - {"gpt-5.4mini", "gpt-5.4-mini"}, - {"gpt-5.4nano", "gpt-5.4-nano"}, - {"gpt-5.3-codexspark", "gpt-5.3-codex-spark"}, - {"gpt-5.3codexspark", "gpt-5.3-codex-spark"}, - {"gpt-5.3codex", "gpt-5.3-codex"}, - } - for _, replacement := range replacements { - normalized = strings.ReplaceAll(normalized, replacement.from, replacement.to) - } - return normalized + return openai.CanonicalizeOpenAIModelAliasSpelling(model) } func normalizeKnownOpenAICodexModel(model string) string { diff --git a/backend/internal/service/openai_model_alias_test.go b/backend/internal/service/openai_model_alias_test.go index 9ac5636892..3cc4557c52 100644 --- a/backend/internal/service/openai_model_alias_test.go +++ b/backend/internal/service/openai_model_alias_test.go @@ -7,7 +7,7 @@ import ( ) func TestNormalizeKnownOpenAICodexModelGPT6Astra(t *testing.T) { - for _, model := range []string{"gpt-6-astra", "openai/gpt-6-astra", "gpt-6", "openai/gpt-6"} { + for _, model := range []string{"gpt-6-astra", "openai/gpt-6-astra", "OPENAI/GPT-6_ASTRA", "gpt-6", "openai/gpt-6"} { require.Equal(t, "gpt-6-astra", normalizeKnownOpenAICodexModel(model)) } } diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 36171ae72d..d871fcd901 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -936,6 +936,44 @@ func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsGetsDefau } } +func TestOpenAIGatewayService_Forward_MissingInstructionsUsesMappedModelTemplate(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil)) + c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_mapped_astra"}}, + Body: io.NopCloser(strings.NewReader(strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`, + "", "data: [DONE]", "", + }, "\n"))), + }} + svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} + account := &Account{ + ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + "model_mapping": map[string]any{"astra-public": "gpt-6-astra"}, + }, + Extra: map[string]any{ + "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff, + }, + Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1), + } + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"astra-public","stream":true,"store":true,"input":[{"type":"text","text":"hi"}]}`)) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "gpt-6-astra", gjson.GetBytes(upstream.lastBody, "model").String()) + instructions := gjson.GetBytes(upstream.lastBody, "instructions").String() + require.True(t, strings.HasPrefix(strings.TrimSpace(instructions), "You are Codex, an agent based on GPT-6.")) + require.NotContains(t, instructions, "You are Codex, a coding agent based on GPT-5.") +} + func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_reasoning_effort_policy.go b/backend/internal/service/openai_reasoning_effort_policy.go index 0d1ad0dc5c..907025597b 100644 --- a/backend/internal/service/openai_reasoning_effort_policy.go +++ b/backend/internal/service/openai_reasoning_effort_policy.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "strings" @@ -19,6 +20,9 @@ const ( ReasoningEffortOverLimitDowngrade = "downgrade" // ReasoningEffortOverLimitDeny rejects the request when the ceiling is exceeded. ReasoningEffortOverLimitDeny = "deny" + // ReasoningEffortMappingDeny is a mapping target that rejects the request + // when the matching source reasoning effort is present. + ReasoningEffortMappingDeny = "deny" ) var openAIReasoningEffortValues = []string{"minimal", "low", "medium", "high", "xhigh", "max"} @@ -31,6 +35,17 @@ func normalizeReasoningEffortMappingSource(raw string) string { return NormalizeMaxReasoningEffort(raw) } +func normalizeReasoningEffortMappingTarget(raw string) string { + if isReasoningEffortMappingDeny(raw) { + return ReasoningEffortMappingDeny + } + return NormalizeMaxReasoningEffort(raw) +} + +func isReasoningEffortMappingDeny(raw string) bool { + return strings.EqualFold(strings.TrimSpace(raw), ReasoningEffortMappingDeny) +} + type openAIReasoningEffortPolicyContextKey struct{} type requestedReasoningEffortContextKey struct{} @@ -65,6 +80,37 @@ func (e *ReasoningEffortOverLimitError) Error() string { return fmt.Sprintf("reasoning effort %q exceeds this group's limit of %q", requested, max) } +// ReasoningEffortMappingDeniedError is returned when a group mapping target is +// set to deny the explicit reasoning effort on the request. +type ReasoningEffortMappingDeniedError struct { + Requested string +} + +func (e *ReasoningEffortMappingDeniedError) Error() string { + if e == nil { + return "reasoning effort is denied by this group's mapping policy" + } + requested := strings.TrimSpace(e.Requested) + if requested == "" { + return "reasoning effort is denied by this group's mapping policy" + } + return fmt.Sprintf("reasoning effort %q is denied by this group's mapping policy", requested) +} + +// IsReasoningEffortPolicyDenied reports whether err is a local reasoning-effort +// policy rejection (ceiling deny or mapping deny). +func IsReasoningEffortPolicyDenied(err error) bool { + if err == nil { + return false + } + var overLimit *ReasoningEffortOverLimitError + if errors.As(err, &overLimit) { + return true + } + var mappingDenied *ReasoningEffortMappingDeniedError + return errors.As(err, &mappingDenied) +} + // NormalizeMaxReasoningEffort validates and canonicalizes a group policy value. // Empty means that the group does not impose a ceiling. func NormalizeMaxReasoningEffort(raw string) string { @@ -298,7 +344,7 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp seen := make(map[string]struct{}, len(raw)) for i, mapping := range raw { from := normalizeReasoningEffortMappingSource(mapping.From) - to := NormalizeMaxReasoningEffort(mapping.To) + to := normalizeReasoningEffortMappingTarget(mapping.To) if from == "" || to == "" { return nil, fmt.Errorf("reasoning effort mapping %d contains an empty or unknown value", i+1) } @@ -315,8 +361,18 @@ func NormalizeReasoningEffortMappings(platform string, raw []ReasoningEffortMapp i+1, PlatformOpenAI, PlatformComposite, ) } - if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil { - return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err) + if to != ReasoningEffortMappingDeny { + if _, err := normalizeMaxReasoningEffortForPlatform(platform, to); err != nil { + return nil, fmt.Errorf("reasoning effort mapping %d target: %w", i+1, err) + } + } else if len(reasoningEffortValuesForPlatform(platform)) == 0 { + return nil, fmt.Errorf( + "reasoning effort mapping %d target: reasoning effort policy is only supported for platforms %q, %q, and %q", + i+1, + PlatformAnthropic, + PlatformOpenAI, + PlatformComposite, + ) } model := strings.TrimSpace(mapping.Model) if len(model) > maxReasoningEffortModelLen { @@ -439,9 +495,10 @@ func sanitizeGroupReasoningEffortPolicy(group *Group) { // ApplyReasoningEffortPolicy applies one mapping (optionally scoped to // the request model by exact name, prefix, or suffix) and then either caps // known effort levels or rejects the request when the group is configured to -// deny values above the ceiling. Omitted values remain untouched so upstream -// defaults stay in control. It understands both OpenAI and Anthropic request -// field shapes. +// deny values above the ceiling. A mapping whose target is deny rejects the +// request when that source value is present. Omitted values remain untouched +// so upstream defaults stay in control. It understands both OpenAI and +// Anthropic request field shapes. func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []ReasoningEffortMapping, overLimit string) ([]byte, bool, error) { maxRank, hasMax := reasoningEffortRank(maxEffort) if len(body) == 0 || (!hasMax && len(mappings) == 0) { @@ -463,7 +520,14 @@ func ApplyReasoningEffortPolicy(body []byte, maxEffort string, mappings []Reason continue } - effective, _ := mapReasoningEffort(original, mappings, requestModel) + effective, mapped := mapReasoningEffort(original, mappings, requestModel) + if mapped && isReasoningEffortMappingDeny(effective) { + requested := normalizeReasoningEffortMappingSource(original) + if requested == "" { + requested = original + } + return body, false, &ReasoningEffortMappingDeniedError{Requested: requested} + } if currentRank, recognized := reasoningEffortRank(effective); recognized { effective = NormalizeMaxReasoningEffort(effective) if hasMax && currentRank > maxRank { diff --git a/backend/internal/service/openai_reasoning_effort_policy_test.go b/backend/internal/service/openai_reasoning_effort_policy_test.go index f3697bbe86..62cc394c3d 100644 --- a/backend/internal/service/openai_reasoning_effort_policy_test.go +++ b/backend/internal/service/openai_reasoning_effort_policy_test.go @@ -167,6 +167,27 @@ func TestNormalizeReasoningEffortMappings(t *testing.T) { require.ErrorContains(t, err, "empty or unknown") }) + t.Run("allows deny as a mapping target", func(t *testing.T) { + for _, platform := range []string{PlatformOpenAI, PlatformComposite, PlatformAnthropic} { + got, err := NormalizeReasoningEffortMappings(platform, []ReasoningEffortMapping{{From: " XHIGH ", To: " DENY "}}) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, got) + } + + got, err := NormalizeReasoningEffortMappings(PlatformOpenAI, []ReasoningEffortMapping{ + {From: "none", To: "deny"}, + {From: "max", To: "deny", MatchType: "prefix", Model: "gpt"}, + }) + require.NoError(t, err) + require.Equal(t, []ReasoningEffortMapping{ + {From: "none", To: ReasoningEffortMappingDeny}, + {From: "max", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt"}, + }, got) + + _, err = NormalizeReasoningEffortMappings(PlatformGemini, []ReasoningEffortMapping{{From: "high", To: "deny"}}) + require.ErrorContains(t, err, "only supported for platforms") + }) + t.Run("supports Anthropic values except minimal", func(t *testing.T) { got, err := NormalizeReasoningEffortMappings(PlatformAnthropic, []ReasoningEffortMapping{{From: " MAX ", To: " x-high "}}) require.NoError(t, err) @@ -255,15 +276,16 @@ func TestOpenAIReasoningEffortPolicyContext(t *testing.T) { func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { tests := []struct { - name string - body string - max string - overLimit string - mappings []ReasoningEffortMapping - path string - want string - changed bool - deny bool + name string + body string + max string + overLimit string + mappings []ReasoningEffortMapping + path string + want string + changed bool + deny bool + mappingDeny bool }{ {name: "nested caps high", body: `{"reasoning":{"effort":"xhigh"}}`, max: "medium", path: "reasoning.effort", want: "medium", changed: true}, {name: "flat caps high", body: `{"reasoning_effort":"high"}`, max: "low", path: "reasoning_effort", want: "low", changed: true}, @@ -419,6 +441,41 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { {name: "deny after mapping still over ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "xhigh"}}, path: "reasoning.effort", want: "max", deny: true}, {name: "deny allows mapping under ceiling", body: `{"reasoning":{"effort":"max"}}`, max: "medium", overLimit: ReasoningEffortOverLimitDeny, mappings: []ReasoningEffortMapping{{From: "max", To: "low"}}, path: "reasoning.effort", want: "low", changed: true}, {name: "deny ignored without ceiling", body: `{"reasoning_effort":"high"}`, overLimit: ReasoningEffortOverLimitDeny, path: "reasoning_effort", want: "high"}, + {name: "mapping deny rejects matching nested effort", body: `{"reasoning":{"effort":"xhigh"}}`, mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, path: "reasoning.effort", want: "xhigh", mappingDeny: true}, + {name: "mapping deny rejects matching flat effort", body: `{"reasoning_effort":"X-HIGH"}`, mappings: []ReasoningEffortMapping{{From: "xhigh", To: " DENY "}}, path: "reasoning_effort", want: "X-HIGH", mappingDeny: true}, + {name: "mapping deny rejects Anthropic output config", body: `{"output_config":{"effort":"max"}}`, mappings: []ReasoningEffortMapping{{From: "max", To: ReasoningEffortMappingDeny}}, path: "output_config.effort", want: "max", mappingDeny: true}, + {name: "mapping deny rejects none source", body: `{"reasoning_effort":"none"}`, mappings: []ReasoningEffortMapping{{From: "none", To: ReasoningEffortMappingDeny}}, path: "reasoning_effort", want: "none", mappingDeny: true}, + { + name: "mapping deny respects model scope", + body: `{"model":"gpt-5.6-sol","reasoning":{"effort":"xhigh"}}`, + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchPrefix, Model: "gpt-5.6-sol"}}, + path: "reasoning.effort", + want: "xhigh", + mappingDeny: true, + }, + { + name: "mapping deny skips non matching model", + body: `{"model":"gpt-5.6-terra","reasoning":{"effort":"xhigh"}}`, + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny, MatchType: domain.ReasoningEffortMatchExact, Model: "gpt-5.6-sol"}}, + path: "reasoning.effort", + want: "xhigh", + }, + { + name: "mapping deny leaves other efforts unchanged", + body: `{"reasoning_effort":"high"}`, + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, + path: "reasoning_effort", + want: "high", + }, + { + name: "mapping deny takes precedence over ceiling rewrite", + body: `{"reasoning":{"effort":"xhigh"}}`, + max: "medium", + mappings: []ReasoningEffortMapping{{From: "xhigh", To: ReasoningEffortMappingDeny}}, + path: "reasoning.effort", + want: "xhigh", + mappingDeny: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -427,6 +484,20 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { require.Error(t, err) var overLimit *ReasoningEffortOverLimitError require.ErrorAs(t, err, &overLimit) + require.True(t, IsReasoningEffortPolicyDenied(err)) + require.False(t, changed) + require.Equal(t, tt.body, string(got)) + return + } + if tt.mappingDeny { + require.Error(t, err) + var mappingDenied *ReasoningEffortMappingDeniedError + require.ErrorAs(t, err, &mappingDenied) + require.True(t, IsReasoningEffortPolicyDenied(err)) + require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy") + if requested := normalizeReasoningEffortMappingSource(tt.want); requested != "" { + require.Equal(t, requested, mappingDenied.Requested) + } require.False(t, changed) require.Equal(t, tt.body, string(got)) return @@ -442,3 +513,14 @@ func TestApplyOpenAIReasoningEffortPolicy(t *testing.T) { }) } } + +func TestReasoningEffortMappingDeniedError(t *testing.T) { + t.Parallel() + + err := &ReasoningEffortMappingDeniedError{Requested: "xhigh"} + require.Equal(t, `reasoning effort "xhigh" is denied by this group's mapping policy`, err.Error()) + require.True(t, IsReasoningEffortPolicyDenied(err)) + require.True(t, IsReasoningEffortPolicyDenied(&ReasoningEffortOverLimitError{Requested: "max", Max: "low"})) + require.False(t, IsReasoningEffortPolicyDenied(nil)) + require.False(t, IsReasoningEffortPolicyDenied(context.Canceled)) +} diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay.go b/backend/internal/service/openai_ws_v2/passthrough_relay.go index fdb539a073..3c6bd076dc 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay.go @@ -102,6 +102,7 @@ type RelayTraceEvent struct { type relayState struct { usage Usage turnUsage Usage + turnWroteDownstream atomic.Bool requestModelMu sync.RWMutex requestModel string pendingTurnStart atomic.Pointer[time.Time] @@ -204,7 +205,8 @@ func Relay( return upstreamConn.WriteFrame(writeCtx, msgType, payload) } writeClientFrameUpstream := func(msgType coderws.MessageType, payload []byte) error { - if isClientResponseCreateFrame(msgType, payload) { + isResponseCreate := isClientResponseCreateFrame(msgType, payload) + if isResponseCreate { state.setRequestModel(strings.TrimSpace(gjson.GetBytes(payload, "model").String())) turnStartedAt := time.Time{} if options.TakeNextTurnStartedAt != nil { @@ -214,8 +216,18 @@ func Relay( turnStartedAt = nowFn() } state.setPendingTurnStartedAt(turnStartedAt) + // The policy-enforcing client connection has accepted this turn. + // Reset before the write so an immediate upstream response cannot race + // with the transport returning from WriteFrame. + state.turnWroteDownstream.Store(false) } - return writeUpstream(msgType, payload) + err := writeUpstream(msgType, payload) + if err != nil && isResponseCreate { + // The relay exits on this error, but retain the previous turn's state + // for accurate diagnostics while the two relay goroutines settle. + state.turnWroteDownstream.Store(true) + } + return err } writeClient := func(msgType coderws.MessageType, payload []byte) error { // 下行写超时故意不挂在 relayCtx 上:coder/websocket 在已武装的 write @@ -538,7 +550,7 @@ func runUpstreamToClient( // the upstream has started a Responses turn, success still requires a // terminal protocol event. Treat an early 1000/EOF as a relay failure so // the adapter does not report relay_completed with an active turn. - if graceful && openAIWSRelayActiveTurnID(state) != "" { + if graceful && state.hasUnfinishedTurn() { graceful = false err = errors.New("upstream websocket closed before terminal event: " + err.Error()) } @@ -559,7 +571,11 @@ func runUpstreamToClient( } markActivity() if beforeWriteClient != nil { - if err := beforeWriteClient(msgType, payload, wroteDownstream); err != nil { + wroteDownstreamInTurn := wroteDownstream + if state != nil { + wroteDownstreamInTurn = state.turnWroteDownstream.Load() + } + if err := beforeWriteClient(msgType, payload, wroteDownstreamInTurn); err != nil { emitRelayTrace(onTrace, RelayTraceEvent{ Stage: "upstream_message_rejected", Direction: "upstream_to_client", @@ -585,7 +601,14 @@ func runUpstreamToClient( } observedEvent = observeUpstreamMessage(state, payload, startAt, nowFn, onUsageParseFailure) case coderws.MessageBinary: - // binary frame 直接透传,不进入 JSON 观测路径(避免无效解析开销)。 + // Binary frames remain opaque for usage/result observation, but a JSON + // terminal still settles relay lifecycle. Otherwise the pending-turn + // disconnect guard would turn an already-delivered terminal into a false + // missing-terminal failure when the upstream closes normally. + if isTerminalEvent(strings.TrimSpace(gjson.GetBytes(payload, "type").String())) { + state.consumePendingTurnStartedAt() + openAIWSRelayDiscardActiveTurnTiming(state) + } } emitTurnComplete(onTurnComplete, state, observedEvent) if dropDownstreamWrites != nil && dropDownstreamWrites.Load() { @@ -630,6 +653,9 @@ func runUpstreamToClient( return } wroteDownstream = true + if state != nil { + state.turnWroteDownstream.Store(true) + } if afterWriteClient != nil { afterWriteClient(msgType, payload) } @@ -995,6 +1021,13 @@ func (s *relayState) consumePendingTurnStartedAt() time.Time { return *startedAt } +func (s *relayState) hasUnfinishedTurn() bool { + if s == nil { + return false + } + return s.pendingTurnStart.Load() != nil || s.activeTurn != nil +} + func openAIWSRelayDeleteTurnTiming(state *relayState, responseID string) (relayTurnTiming, bool) { if state == nil || state.turnTimingByID == nil { return relayTurnTiming{}, false diff --git a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go index a5505b4981..394aa713c5 100644 --- a/backend/internal/service/openai_ws_v2/passthrough_relay_test.go +++ b/backend/internal/service/openai_ws_v2/passthrough_relay_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "strings" "sync" "sync/atomic" "testing" @@ -309,11 +310,46 @@ func TestRelay_UpstreamDisconnect(t *testing.T) { defer cancel() result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) - // 上游 EOF 属于 disconnect,标记为 graceful - require.Nil(t, relayExit, "上游 EOF 应被视为 graceful disconnect") + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") require.Equal(t, "gpt-4o", result.RequestModel) } +func TestRelay_UpstreamNormalCloseBeforeResponseIDIsFailure(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := &eofReplacementFrameConn{ + FrameConn: newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.created","status":"in_progress"}`), + }, + { + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.in_progress","status":"in_progress"}`), + }, + }, true), + err: coderws.CloseError{Code: coderws.StatusNormalClosure}, + } + + firstPayload := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.True(t, relayExit.WroteDownstream) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") + require.Empty(t, result.RequestID) + require.Empty(t, result.TerminalEventType) + require.Len(t, clientConn.Writes(), 2) +} + func TestRelay_UpstreamNormalCloseBeforeTerminalIsFailure(t *testing.T) { t.Parallel() @@ -903,6 +939,63 @@ func TestRelay_OnTurnComplete_UsesSubsequentResponseCreateTimeAcrossPricingBound } } +func TestRelay_BeforeWriteClientTracksDownstreamPerTurn(t *testing.T) { + t.Parallel() + + clientConn := newPassthroughTestFrameConn(nil, false) + upstreamConn := newPassthroughTestFrameConn(nil, false) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wroteStates := make(chan bool, 3) + done := make(chan *RelayExit, 1) + stopErr := errors.New("stop after second-turn error") + go func() { + _, relayExit := Relay( + ctx, + clientConn, + upstreamConn, + []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + RelayOptions{BeforeWriteClient: func(_ coderws.MessageType, payload []byte, wroteDownstream bool) error { + wroteStates <- wroteDownstream + if strings.Contains(string(payload), `"type":"error"`) { + return stopErr + } + return nil + }}, + ) + done <- relayExit + }() + + require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 1 }, time.Second, time.Millisecond) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_first","usage":{"input_tokens":1,"output_tokens":1}}}`), + } + require.False(t, <-wroteStates) + require.Eventually(t, func() bool { return len(clientConn.Writes()) == 1 }, time.Second, time.Millisecond) + + clientConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"response.create","model":"gpt-5.3-codex","input":[]}`), + } + require.Eventually(t, func() bool { return len(upstreamConn.Writes()) == 2 }, time.Second, time.Millisecond) + upstreamConn.readCh <- passthroughTestFrame{ + msgType: coderws.MessageText, + payload: []byte(`{"type":"error","error":{"type":"usage_limit_reached"}}`), + } + require.False(t, <-wroteStates, "the next turn must not inherit the first turn's downstream write") + + select { + case relayExit := <-done: + require.NotNil(t, relayExit) + require.ErrorIs(t, relayExit.Err, stopErr) + require.True(t, relayExit.WroteDownstream, "connection-wide diagnostics must retain prior output") + case <-time.After(time.Second): + t.Fatal("relay did not stop after the rejected second-turn event") + } +} + func TestRelay_BinaryFramePassthrough(t *testing.T) { t.Parallel() @@ -921,7 +1014,10 @@ func TestRelay_BinaryFramePassthrough(t *testing.T) { defer cancel() result, relayExit := Relay(ctx, clientConn, upstreamConn, firstPayload, RelayOptions{}) - require.Nil(t, relayExit) + require.NotNil(t, relayExit) + require.Equal(t, "read_upstream", relayExit.Stage) + require.False(t, relayExit.Graceful) + require.ErrorContains(t, relayExit.Err, "upstream websocket closed before terminal event") // binary frame 不解析 usage require.Equal(t, 0, result.Usage.InputTokens) @@ -986,7 +1082,12 @@ func TestRelay_PreservesFirstMessageType(t *testing.T) { t.Parallel() clientConn := newPassthroughTestFrameConn(nil, false) - upstreamConn := newPassthroughTestFrameConn(nil, true) + upstreamConn := newPassthroughTestFrameConn([]passthroughTestFrame{ + { + msgType: coderws.MessageBinary, + payload: []byte(`{"type":"response.completed","response":{"id":"resp_binary_first_type"}}`), + }, + }, true) firstPayload := []byte(`{"type":"response.create","model":"gpt-4o","input":[]}`) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) @@ -1001,6 +1102,9 @@ func TestRelay_PreservesFirstMessageType(t *testing.T) { require.Len(t, upstreamWrites, 1) require.Equal(t, coderws.MessageBinary, upstreamWrites[0].msgType) require.Equal(t, firstPayload, upstreamWrites[0].payload) + clientWrites := clientConn.Writes() + require.Len(t, clientWrites, 1) + require.Equal(t, coderws.MessageBinary, clientWrites[0].msgType) } func TestRelay_UsageParseFailureDoesNotBlockRelay(t *testing.T) { diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 0d7f1095e4..556ad06274 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -1304,6 +1304,13 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( truncateOpenAIWSLogValue(errTypeRaw, openAIWSLogValueMaxLen), truncateOpenAIWSLogValue(errMsgRaw, openAIWSLogValueMaxLen), ) + if completedTurns.Load() > 0 { + return NewOpenAIWSClientCloseError( + coderws.StatusTryAgainLater, + "upstream rate limit exceeded; please reconnect", + errors.New("later passthrough turn was rate limited before output"), + ) + } return s.newOpenAIWSRateLimitFailoverError(account, handshakeHeaders, payload, errMsgRaw) }, OnTrace: func(event openaiwsv2.RelayTraceEvent) { diff --git a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go index 5fa6a7def9..24afbeda09 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go +++ b/backend/internal/service/openai_ws_v2_passthrough_lifecycle_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "strings" @@ -210,6 +211,61 @@ func startPassthroughLifecycleServerWithHooks( return server, serverErr } +func TestPassthroughLifecycle_LaterTurnPreOutputRateLimitRequestsReconnect(t *testing.T) { + gin.SetMode(gin.TestMode) + controlCtx, cancelControl := context.WithCancelCause(context.Background()) + defer cancelControl(context.Canceled) + upstream := newStagedPassthroughConn() + account := passthroughLifecycleAccount() + repo := &openAIWSRateLimitSignalRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{*account}}} + svc := newPassthroughLifecycleService(passthroughLifecycleConfig(), upstream) + svc.accountRepo = repo + svc.rateLimitService = &RateLimitService{accountRepo: repo} + + server, serverErr := startPassthroughLifecycleServer(t, controlCtx, svc, account) + defer server.Close() + clientConn := dialPassthroughLifecycleClient(t, server) + defer func() { _ = clientConn.CloseNow() }() + firstRequest := requirePassthroughUpstreamWrite(t, upstream, time.Second) + require.Equal(t, "response.create", gjson.GetBytes(firstRequest, "type").String()) + + upstream.Send(`{"type":"response.completed","response":{"id":"resp_first","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`) + completed, err := readPassthroughLifecycleFrame(t, clientConn, time.Second) + require.NoError(t, err) + require.Equal(t, "response.completed", gjson.GetBytes(completed, "type").String()) + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","stream":false}`)) + cancelWrite() + require.NoError(t, err) + secondRequest := requirePassthroughUpstreamWrite(t, upstream, time.Second) + require.Equal(t, "response.create", gjson.GetBytes(secondRequest, "type").String()) + + resetAt := time.Now().Add(90 * time.Minute).Unix() + upstream.Send(fmt.Sprintf(`{"type":"error","error":{"code":"rate_limit_exceeded","type":"usage_limit_reached","message":"The usage limit has been reached","resets_at":%d}}`, resetAt)) + _, err = readPassthroughLifecycleFrame(t, clientConn, time.Second) + var websocketCloseErr coderws.CloseError + require.ErrorAs(t, err, &websocketCloseErr) + require.Equal(t, coderws.StatusTryAgainLater, websocketCloseErr.Code) + require.Equal(t, "upstream rate limit exceeded; please reconnect", websocketCloseErr.Reason) + require.Len(t, repo.rateLimitCalls, 1) + require.WithinDuration(t, time.Unix(resetAt, 0), repo.rateLimitCalls[0], 2*time.Second) + + select { + case err := <-serverErr: + var closeErr *OpenAIWSClientCloseError + require.ErrorAs(t, err, &closeErr) + require.Equal(t, coderws.StatusTryAgainLater, closeErr.StatusCode()) + case <-time.After(time.Second): + t.Fatal("later-turn rate limit did not terminate passthrough") + } + select { + case replay := <-upstream.writes: + t.Fatalf("later-turn reconnect must not replay the retained first request: %s", replay) + default: + } +} + func TestPassthroughLifecycle_CyberTerminalEventsMarkBeforeAfterTurn(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/payment_fulfillment_test.go b/backend/internal/service/payment_fulfillment_test.go index 3f1fdb1cf6..b1366c3c96 100644 --- a/backend/internal/service/payment_fulfillment_test.go +++ b/backend/internal/service/payment_fulfillment_test.go @@ -764,7 +764,7 @@ func TestFulfillmentLeaseVersionRejectsStaleWorker(t *testing.T) { } func TestPublicRedeemStillEnforcesFailureLimit(t *testing.T) { - cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour} + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts} svc := &RedeemService{cache: cache} result, err := svc.Redeem(context.Background(), 42, "PUBLIC-CODE") @@ -794,7 +794,7 @@ func TestPublicRedeemStillIncrementsInvalidCodeFailures(t *testing.T) { func TestPaymentRedeemDoesNotIncrementFailureLimit(t *testing.T) { ctx := context.Background() - cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour} + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts} redeemRepo := &redeemRejectRepo{code: RedeemCode{ ID: 1, Code: "PAY-EXPIRED", @@ -835,7 +835,7 @@ func TestExecuteBalanceFulfillmentBypassesUserRedeemRateLimit(t *testing.T) { credited += amount return nil } - cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour} + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts} redeemService := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, nil) svc := &PaymentService{entClient: client, redeemService: redeemService, userRepo: userRepo} diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index ba39465f52..75b2fc6a9c 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -1207,7 +1207,7 @@ func (s *PricingService) buildModelLookupCandidates(modelLower string) []string normalized := normalizeModelNameForPricing(modelLower) // A tier-specific entry should take precedence when the pricing catalog gains - // one later. Today Antigravity's Gemini 3.6 Flash tiers share the base rate, + // one later. Antigravity's Gemini Flash thinking tiers share the base rate, // so the normalized base remains the fallback after the exact aliases. candidates := rawCandidates if normalizeGeminiThinkingTierAlias(lastSegment(modelLower)) != lastSegment(modelLower) { @@ -1269,15 +1269,16 @@ func normalizeModelNameForPricing(model string) string { return normalizeGeminiThinkingTierAlias(model) } -// normalizeGeminiThinkingTierAlias maps Antigravity's Gemini 3.6 Flash +// normalizeGeminiThinkingTierAlias maps Antigravity's Gemini Flash // thinking-tier model IDs to the public base model. The tier controls reasoning // behavior, not the published token rate, so this keeps -high/-low/-medium and -// -tiered requests on the same price card as gemini-3.6-flash. +// -tiered requests on the corresponding base model's price card. func normalizeGeminiThinkingTierAlias(model string) string { - const baseModel = "gemini-3.6-flash" - for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { - if model == baseModel+tier { - return baseModel + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + for _, tier := range []string{"-high", "-low", "-medium", "-tiered"} { + if model == baseModel+tier { + return baseModel + } } } return model diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index d65271dd16..3915666c38 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -566,38 +566,37 @@ func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) { require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12) } -func TestPricingService_Gemini36FlashThinkingTiersUseBasePricing(t *testing.T) { +func TestPricingService_GeminiFlashThinkingTiersUseBasePricing(t *testing.T) { basePricing := &LiteLLMModelPricing{ InputCostPerToken: 1.5e-6, OutputCostPerToken: 7.5e-6, CacheReadInputTokenCost: 0.15e-6, } - svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ - "gemini-3.6-flash": basePricing, - }} - - for _, model := range []string{ - "gemini-3.6-flash", - "gemini-3.6-flash-high", - "gemini-3.6-flash-low", - "gemini-3.6-flash-medium", - "gemini-3.6-flash-tiered", - } { - t.Run(model, func(t *testing.T) { - require.Same(t, basePricing, svc.GetModelPricing(model)) - }) + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{baseModel: basePricing}} + for _, tier := range []string{"", "-high", "-low", "-medium", "-tiered"} { + model := baseModel + tier + t.Run(model, func(t *testing.T) { + require.Same(t, basePricing, svc.GetModelPricing(model)) + require.Same(t, basePricing, svc.GetIdentifiedModelPricing("models/"+model)) + }) + } } } -func TestPricingService_Gemini36FlashTierSpecificPricingTakesPrecedence(t *testing.T) { +func TestPricingService_GeminiFlashTierSpecificPricingTakesPrecedence(t *testing.T) { basePricing := &LiteLLMModelPricing{InputCostPerToken: 1.5e-6} tierPricing := &LiteLLMModelPricing{InputCostPerToken: 2e-6} - svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ - "gemini-3.6-flash": basePricing, - "gemini-3.6-flash-low": tierPricing, - }} + for _, baseModel := range []string{"gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash"} { + t.Run(baseModel, func(t *testing.T) { + svc := &PricingService{pricingData: map[string]*LiteLLMModelPricing{ + baseModel: basePricing, + baseModel + "-low": tierPricing, + }} - require.Same(t, tierPricing, svc.GetModelPricing("models/gemini-3.6-flash-low")) + require.Same(t, tierPricing, svc.GetModelPricing("models/"+baseModel+"-low")) + }) + } } func TestBillingService_Gemini36FlashThinkingTierFallbacksAreBillable(t *testing.T) { diff --git a/backend/internal/service/redeem_admin_fulfillment_test.go b/backend/internal/service/redeem_admin_fulfillment_test.go index 76e4ff4832..1e7a32c5b0 100644 --- a/backend/internal/service/redeem_admin_fulfillment_test.go +++ b/backend/internal/service/redeem_admin_fulfillment_test.go @@ -25,7 +25,7 @@ func TestAdminFulfillmentBypassesLimitAndKeepsRedeemAffiliate(t *testing.T) { } userRepo := &mockUserRepo{getByIDUser: &User{ID: userID}} userRepo.updateBalanceFn = func(context.Context, int64, float64) error { return nil } - cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxErrorsPerHour} + cache := &paymentFulfillmentRedeemCacheStub{count: redeemMaxFailedAttempts} affiliateRepo := &paymentFulfillmentAffiliateRepoStub{ inviteeSummary: &AffiliateSummary{ UserID: userID, AffCode: "INVITEE", InviterID: &inviterID, CreatedAt: time.Now().Add(-time.Hour), diff --git a/backend/internal/service/redeem_rate_limit_test.go b/backend/internal/service/redeem_rate_limit_test.go new file mode 100644 index 0000000000..7beb302935 --- /dev/null +++ b/backend/internal/service/redeem_rate_limit_test.go @@ -0,0 +1,176 @@ +//go:build unit + +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// redeemRateLimitCacheStub 实现 RedeemCache,记录各方法调用次数并允许注入错误。 +type redeemRateLimitCacheStub struct { + count int + getErr error + incrementErr error + getCalls int + incrementCalls int + acquireCalls int + releaseCalls int +} + +func (c *redeemRateLimitCacheStub) GetRedeemAttemptCount(context.Context, int64) (int, error) { + c.getCalls++ + return c.count, c.getErr +} + +func (c *redeemRateLimitCacheStub) IncrementRedeemAttemptCount(context.Context, int64) error { + c.incrementCalls++ + if c.incrementErr != nil { + return c.incrementErr + } + c.count++ + return nil +} + +func (c *redeemRateLimitCacheStub) AcquireRedeemLock(context.Context, string, time.Duration) (bool, error) { + c.acquireCalls++ + return true, nil +} + +func (c *redeemRateLimitCacheStub) ReleaseRedeemLock(context.Context, string) error { + c.releaseCalls++ + return nil +} + +func TestPublicRedeemAllowsLastAttemptBeforeLimit(t *testing.T) { + cache := &redeemRateLimitCacheStub{count: redeemMaxFailedAttempts - 1} + redeemRepo := &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}} + svc := &RedeemService{redeemRepo: redeemRepo, cache: cache} + + result, err := svc.Redeem(context.Background(), 42, "MISSING") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeNotFound) + require.Equal(t, redeemMaxFailedAttempts, cache.count) + require.Equal(t, 1, cache.getCalls) + require.Equal(t, 1, cache.incrementCalls) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) +} + +func TestPublicRedeemCountsOnlyConfiguredDomainFailures(t *testing.T) { + past := time.Now().Add(-time.Minute) + tests := []struct { + name string + code RedeemCode + input string + wantErr error + }{ + { + name: "not found", + code: RedeemCode{Code: "OTHER"}, + input: "MISSING", + wantErr: ErrRedeemCodeNotFound, + }, + { + name: "expired", + code: RedeemCode{ + ID: 1, Code: "EXPIRED", Type: RedeemTypeBalance, Value: 10, + Status: StatusUnused, ExpiresAt: &past, + }, + input: "EXPIRED", + wantErr: ErrRedeemCodeExpired, + }, + { + name: "used", + code: RedeemCode{ + ID: 2, Code: "USED", Type: RedeemTypeBalance, Value: 10, + Status: StatusUsed, + }, + input: "USED", + wantErr: ErrRedeemCodeUsed, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cache := &redeemRateLimitCacheStub{} + svc := &RedeemService{ + redeemRepo: &redeemRejectRepo{code: tt.code}, + cache: cache, + } + + result, err := svc.Redeem(context.Background(), 42, tt.input) + + require.Nil(t, result) + require.ErrorIs(t, err, tt.wantErr) + require.Equal(t, 1, cache.getCalls) + require.Equal(t, 1, cache.incrementCalls) + require.Equal(t, 1, cache.count) + }) + } +} + +func TestPublicRedeemRateLimitFailsOpenWhenCounterReadFails(t *testing.T) { + cache := &redeemRateLimitCacheStub{ + count: redeemMaxFailedAttempts, + getErr: errors.New("redis unavailable"), + } + svc := &RedeemService{ + redeemRepo: &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}}, + cache: cache, + } + + result, err := svc.Redeem(context.Background(), 42, "MISSING") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeNotFound) + require.Equal(t, 1, cache.getCalls) + require.Equal(t, 1, cache.incrementCalls) +} + +func TestPublicRedeemCounterWriteFailurePreservesDomainError(t *testing.T) { + cache := &redeemRateLimitCacheStub{incrementErr: errors.New("redis unavailable")} + svc := &RedeemService{ + redeemRepo: &redeemRejectRepo{code: RedeemCode{Code: "OTHER"}}, + cache: cache, + } + + result, err := svc.Redeem(context.Background(), 42, "MISSING") + + require.Nil(t, result) + require.ErrorIs(t, err, ErrRedeemCodeNotFound) + require.Equal(t, 1, cache.incrementCalls) + require.Zero(t, cache.count) +} + +func TestSuccessfulPublicRedeemDoesNotMutateFailureCounter(t *testing.T) { + ctx := context.Background() + client := newPaymentConfigServiceTestClient(t) + userID := int64(42) + code := &RedeemCode{ + ID: 101, Code: "PUBLIC-SUCCESS", Type: RedeemTypeBalance, Value: 10, Status: StatusUnused, + } + redeemRepo := &paymentOrderLifecycleRedeemRepo{ + codesByCode: map[string]*RedeemCode{code.Code: code}, + } + userRepo := &mockUserRepo{getByIDUser: &User{ID: userID}} + userRepo.updateBalanceFn = func(context.Context, int64, float64) error { return nil } + cache := &redeemRateLimitCacheStub{count: 7} + svc := NewRedeemService(redeemRepo, userRepo, nil, cache, nil, client, nil, nil) + + result, err := svc.Redeem(ctx, userID, code.Code) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, cache.getCalls) + require.Zero(t, cache.incrementCalls) + require.Equal(t, 7, cache.count) + require.Equal(t, 1, cache.acquireCalls) + require.Equal(t, 1, cache.releaseCalls) + require.Len(t, redeemRepo.useCalls, 1) +} diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 7be6871f07..5e7cdee685 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -25,8 +25,7 @@ var ( ) const ( - redeemMaxErrorsPerHour = 20 - redeemRateLimitDuration = time.Hour + redeemMaxFailedAttempts = 30 redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁 ) @@ -344,7 +343,7 @@ func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) return nil } - if count >= redeemMaxErrorsPerHour { + if count >= redeemMaxFailedAttempts { return ErrRedeemRateLimited } diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index c998fc3efb..5c3f83542e 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -31,6 +31,10 @@ export interface UserPricingInterval { cache_write_price: number | null cache_write_1h_price?: number | null cache_read_price: number | null + input_multiplier?: number | null + output_multiplier?: number | null + cache_write_multiplier?: number | null + cache_read_multiplier?: number | null per_request_price: number | null } diff --git a/frontend/src/api/modelPlaza.ts b/frontend/src/api/modelPlaza.ts index e96a621934..5096efbe82 100644 --- a/frontend/src/api/modelPlaza.ts +++ b/frontend/src/api/modelPlaza.ts @@ -46,7 +46,7 @@ export interface PlazaTimePricing { export interface PlazaModel { name: string platform: string - /** 实收口径的展示定价:多档时 intervals 为各档绝对单价(已由计费服务折算);均为标准时段价。 */ + /** 实收口径的展示定价:档位可提供绝对单价或相对基础价倍率;均为标准时段价。 */ pricing: UserSupportedModelPricing | null official_pricing: PlazaOfficialPricing | null /** 仅多档模型返回。 */ diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index e87bda2b4c..e2e85e3274 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2961,6 +2961,14 @@
+
+ + +

{{ t('admin.accounts.expiresAtHint') }} {{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }} @@ -3867,6 +3875,7 @@ import { parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' +import { getAccountExpiryTimestamp } from '@/components/account/accountExpiry' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' import { OPENAI_WS_MODE_CTX_POOL, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index b4b9d9f299..6fe23eef0b 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1617,6 +1617,14 @@

+
+ + +

{{ t('admin.accounts.expiresAtHint') }} {{ t('admin.accounts.expiresAtTimezoneHint', { timezone: browserTimeZone }) }} @@ -2963,6 +2971,7 @@ import { parseDateTimeLocalInput } from '@/utils/format' import { createStableObjectKeyResolver } from '@/utils/stableObjectKey' +import { getAccountExpiryTimestamp } from '@/components/account/accountExpiry' import { allSelectedGroupsEnableLongContextPricing } from '@/components/account/longContextBilling' import { VERTEX_LOCATION_OPTIONS } from '@/constants/account' import { diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index c13a69fbdd..a1a4d80a3e 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -1,6 +1,6 @@ import { defineComponent } from 'vue' import { flushPromises, mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { createAccountMock, @@ -212,6 +212,50 @@ describe('CreateAccountModal OpenAI long-context billing', () => { createOpenAICodexPATMock.mockReset().mockResolvedValue({}) }) + afterEach(() => vi.useRealTimers()) + + it('sets month and year expiry presets without submitting the account form', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-01-31T12:34:00')) + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('expiry account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + const input = wrapper.get('input[type="datetime-local"]') + + for (const [label, expected] of [ + ['payment.oneMonth', '2026-02-28T12:34'], + ['payment.oneYear', '2027-01-31T12:34'], + ]) { + const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)! + expect(button.attributes('type')).toBe('button') + await button.trigger('click') + expect(input.element.value).toBe(expected) + expect(createAccountMock).not.toHaveBeenCalled() + } + + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2027-01-31T12:34:00').getTime() / 1000) + wrapper.unmount() + }) + + it('allows a manually entered expiry to override a preset before account creation', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'OpenAI') + await selectButtonByText(wrapper, 'API Key') + await wrapper.get('form#create-account-form input[type="text"]').setValue('custom expiry account') + await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') + await selectButtonByText(wrapper, 'payment.oneMonth') + await wrapper.get('input[type="datetime-local"]').setValue('2030-04-15T09:20') + + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + expect(createAccountMock.mock.calls[0]?.[0]?.expires_at).toBe(new Date('2030-04-15T09:20:00').getTime() / 1000) + wrapper.unmount() + }) + it('hides only the redundant account toggle when every selected group enables tier pricing', async () => { authIsSimpleMode.value = false const wrapper = mountModal([ diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 7d50e0f91f..5c85b7c2af 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent } from 'vue' import { mount } from '@vue/test-utils' @@ -328,6 +328,50 @@ describe('EditAccountModal', () => { authIsSimpleMode.value = true }) + afterEach(() => vi.useRealTimers()) + + it('sets expiry presets from now instead of extending the saved expiry', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2028-02-29T12:34:00')) + const account = buildAccount() + account.expires_at = new Date('2030-06-15T09:00:00').getTime() / 1000 + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + const wrapper = mountModal(account) + const input = wrapper.get('input[type="datetime-local"]') + + for (const [label, expected] of [ + ['payment.oneMonth', '2028-03-29T12:34'], + ['payment.oneYear', '2029-02-28T12:34'], + ]) { + const button = wrapper.findAll('button').find((candidate) => candidate.text() === label)! + expect(button.attributes('type')).toBe('button') + await button.trigger('click') + expect(input.element.value).toBe(expected) + expect(updateAccountMock).not.toHaveBeenCalled() + } + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(new Date('2029-02-28T12:34:00').getTime() / 1000) + wrapper.unmount() + }) + + it('can clear a selected expiry preset before saving the account', async () => { + const account = buildAccount() + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + const wrapper = mountModal(account) + const button = wrapper.findAll('button').find((candidate) => candidate.text() === 'payment.oneYear')! + await button.trigger('click') + const input = wrapper.get('input[type="datetime-local"]') + expect(input.element.value).not.toBe('') + await input.setValue('') + + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + expect(updateAccountMock.mock.calls[0]?.[1]?.expires_at).toBe(0) + wrapper.unmount() + }) + it('reopening the same account rehydrates the OpenAI whitelist from props', async () => { const account = buildAccount() updateAccountMock.mockReset() diff --git a/frontend/src/components/account/__tests__/accountExpiry.spec.ts b/frontend/src/components/account/__tests__/accountExpiry.spec.ts new file mode 100644 index 0000000000..7d243a8434 --- /dev/null +++ b/frontend/src/components/account/__tests__/accountExpiry.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { getAccountExpiryTimestamp } from '../accountExpiry' + +describe('getAccountExpiryTimestamp', () => { + it.each([ + ['2026-01-31T12:34:56.789', 1, '2026-02-28T12:34:56'], + ['2028-01-31T12:34:56.789', 1, '2028-02-29T12:34:56'], + ['2028-02-29T12:34:56.789', 12, '2029-02-28T12:34:56'], + ['2026-12-31T12:34:56.789', 1, '2027-01-31T12:34:56'], + // With TZ=Australia/Melbourne, October 1 contains a DST gap; avoid using it as an intermediate date. + ['2028-10-31T02:30:00', 1, '2028-11-30T02:30:00'], + ])('adds %s by %i calendar months with month-end clamping', (input, months, expected) => { + const now = new Date(input) + const originalTimestamp = now.getTime() + + expect(getAccountExpiryTimestamp(months, now)).toBe(new Date(expected).getTime() / 1000) + expect(now.getTime()).toBe(originalTimestamp) + }) +}) diff --git a/frontend/src/components/account/accountExpiry.ts b/frontend/src/components/account/accountExpiry.ts new file mode 100644 index 0000000000..33665d340c --- /dev/null +++ b/frontend/src/components/account/accountExpiry.ts @@ -0,0 +1,7 @@ +/** Set an expiry from local calendar months, clamping month-end dates. */ +export function getAccountExpiryTimestamp(months: number, now = new Date()): number { + const expiry = new Date(now.getTime()) + const lastDay = new Date(expiry.getFullYear(), expiry.getMonth() + months + 1, 0).getDate() + expiry.setMonth(expiry.getMonth() + months, Math.min(expiry.getDate(), lastDay)) + return Math.floor(expiry.getTime() / 1000) +} diff --git a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue index b16107162f..ba9755db89 100644 --- a/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue +++ b/frontend/src/components/admin/group/ReasoningEffortPolicyFields.vue @@ -164,7 +164,7 @@