From 6ca4efabbdfdeabaf36c4263300d48bae2ac483a Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 18:12:54 +0800 Subject: [PATCH 01/39] fix(groups): make profit control upstream's again in the backend Group profit control (upstream #4925) was switched off downstream by a compile-time kill switch, a 400 on any profit_* field in group create and update, and constant false/0/0 values in the API-key auth snapshot. The owner decided on 2026-10-03 that the feature is upstream's again: editable per group, off by default. - openai_profit_control.go, gateway_profit_control.go, group.go, admin_group_duplicate.go and handler/admin/group_handler.go are byte-identical to v0.2.13 again, and the profit-preview command with service/profit_preview.go is back. - The auth snapshot carries the stored group values. Its version becomes 28: version 27 entries hold the constants instead of the stored values, and the gate is built from the cached group without a database read, so they have to be reloaded. - The legacy selection failure summary counts profit vetoes again instead of counting a vetoed account as eligible. - Upstream's comments at the gate call sites are restored where the fork had shortened or dropped them. - Tests: upstream's profit tests replace the rewrites that pinned the switched-off state; profit_control_dormant_regression_test.go and TestGroupHandlerRejectsLegacyProfitControlWrites are removed; the simple-mode normalization tests send the profit fields again. No migration: the three columns come from 192, and the invalidation trigger function as last defined by 260 has the body of 193. A group that leaves profit control off gets no gate, so scheduling is unchanged for it. The profit code still differs from v0.2.13 only where a downstream contract wraps it: the runtime-breaker probe on selection results, the single repository read of the previous-response owner, Codex quality runs, the WebSocket local close error, the scheduler-based token count selection and the (bool, reason) eligibility helpers. Co-Authored-By: Claude Opus 5.5 --- backend/cmd/profit-preview/main.go | 237 ++++++++++++++++++ backend/cmd/profit-preview/main_test.go | 92 +++++++ .../admin/admin_basic_handlers_test.go | 38 --- .../internal/handler/admin/group_handler.go | 28 ++- .../group_mapper_profit_visibility_test.go | 9 +- backend/internal/handler/dto/mappers.go | 2 - .../handler/failover_loop_profit_veto_test.go | 139 ++++++++++ .../openai_profit_slot_recheck_test.go | 18 +- .../handler/openai_profit_veto_budget_test.go | 74 ++++++ .../internal/service/admin_group_duplicate.go | 3 + backend/internal/service/admin_service.go | 2 +- .../service/admin_service_group_test.go | 5 +- .../service/api_key_auth_cache_impl.go | 14 +- .../service/api_key_auth_cache_profit_test.go | 24 +- .../service/gateway_profit_control.go | 3 - .../service/gateway_profit_control_v2_test.go | 109 +++----- .../internal/service/gateway_scheduling.go | 14 +- backend/internal/service/gateway_service.go | 20 +- backend/internal/service/group.go | 13 +- .../service/group_profit_platform_test.go | 20 ++ .../service/openai_account_scheduler.go | 2 + .../service/openai_gateway_scheduling.go | 7 +- .../internal/service/openai_gateway_usage.go | 27 +- .../internal/service/openai_profit_control.go | 20 -- ..._profit_control_legacy_diagnostics_test.go | 26 +- .../openai_profit_control_paths_test.go | 123 ++++----- .../openai_profit_control_pricing_test.go | 138 +++++----- .../service/openai_profit_control_test.go | 67 +++-- .../service/openai_ws_forwarder_support.go | 5 + .../profit_control_dormant_regression_test.go | 111 -------- backend/internal/service/profit_preview.go | 211 ++++++++++++++++ .../internal/service/profit_preview_test.go | 146 +++++++++++ .../service/scheduler_snapshot_service.go | 5 +- 33 files changed, 1236 insertions(+), 516 deletions(-) create mode 100644 backend/cmd/profit-preview/main.go create mode 100644 backend/cmd/profit-preview/main_test.go create mode 100644 backend/internal/handler/failover_loop_profit_veto_test.go create mode 100644 backend/internal/handler/openai_profit_veto_budget_test.go create mode 100644 backend/internal/service/group_profit_platform_test.go delete mode 100644 backend/internal/service/profit_control_dormant_regression_test.go create mode 100644 backend/internal/service/profit_preview.go create mode 100644 backend/internal/service/profit_preview_test.go diff --git a/backend/cmd/profit-preview/main.go b/backend/cmd/profit-preview/main.go new file mode 100644 index 000000000000..2c351410c922 --- /dev/null +++ b/backend/cmd/profit-preview/main.go @@ -0,0 +1,237 @@ +// profit-preview 读取生产只读导出的 JSON(分组利润配置、账号倍率与探测状态、 +// 用户覆盖倍率、主力模型清单),复用线上 U/D/阈值判定做五平台离线预演。 +// +// 用法: +// +// go run ./cmd/profit-preview -input dump.json [-assume-enabled] [-json] +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type inputGroup struct { + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + RateMultiplier float64 `json:"rate_multiplier"` + SubscriptionType string `json:"subscription_type"` + ProfitControlEnabled bool `json:"profit_control_enabled"` + ProfitMinMargin float64 `json:"profit_min_margin"` + ProfitSafetyBuffer float64 `json:"profit_safety_buffer"` + PeakRateEnabled bool `json:"peak_rate_enabled"` + PeakStart string `json:"peak_start"` + PeakEnd string `json:"peak_end"` + PeakRateMultiplier float64 `json:"peak_rate_multiplier"` +} + +type inputAccount struct { + ID int64 `json:"id"` + Name string `json:"name"` + Platform string `json:"platform"` + Type string `json:"type"` + RateMultiplier *float64 `json:"rate_multiplier"` + Extra map[string]any `json:"extra"` + ModelMapping map[string]string `json:"model_mapping"` +} + +type inputEntry struct { + Group inputGroup `json:"group"` + Accounts []inputAccount `json:"accounts"` + UserOverrides map[string]*float64 `json:"user_overrides"` + Models []string `json:"models"` +} + +type inputDoc struct { + Groups []inputEntry `json:"groups"` +} + +func main() { + inputPath := flag.String("input", "", "生产只读导出 JSON 路径") + assumeEnabled := flag.Bool("assume-enabled", false, "把当前关闭的支持平台分组按保存配置视为已启用") + jsonOut := flag.Bool("json", false, "以 JSON 输出完整报告(默认输出可读表格)") + flag.Parse() + if *inputPath == "" { + fmt.Fprintln(os.Stderr, "usage: profit-preview -input dump.json [-assume-enabled] [-json]") + os.Exit(2) + } + raw, err := os.ReadFile(*inputPath) + if err != nil { + fmt.Fprintf(os.Stderr, "read input: %v\n", err) + os.Exit(1) + } + inputs, err := parsePreviewInputs(raw, *assumeEnabled) + if err != nil { + fmt.Fprintf(os.Stderr, "parse input: %v\n", err) + os.Exit(1) + } + + evalAt := time.Now() + reports := service.PreviewProfitAdmission(inputs, evalAt) + if len(reports) == 0 { + fmt.Fprintln(os.Stderr, "input produced no preview reports") + os.Exit(1) + } + + if *jsonOut { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"evaluated_at": evalAt, "reports": reports}); err != nil { + fmt.Fprintf(os.Stderr, "write output: %v\n", err) + os.Exit(1) + } + return + } + + fmt.Printf("利润门预演 @ %s(U=账号倍率;探测状态仅告警)\n", evalAt.Format(time.RFC3339)) + for _, report := range reports { + fmt.Printf("\n== 分组 %d %s [%s] ==\n", report.GroupID, report.GroupName, report.Platform) + fmt.Printf(" 利润门生效=%v 假定启用=%v | 默认 D=%.4f 阈值=%.4f | 最低有效 D=%.4f 阈值=%.4f\n", + report.EffectiveGate, report.AssumedEnabled, + report.DefaultD, report.ThresholdDefault, report.MinEffectiveD, report.ThresholdMinD) + counts := map[string]int{} + for _, v := range report.Verdicts { + counts[v.Class]++ + rate := "-" + if v.AccountRate != nil { + rate = fmt.Sprintf("%.4f", *v.AccountRate) + } + flags := make([]string, 0, 2) + if v.RejectedUnderMinD { + flags = append(flags, "最低有效D下拒绝") + } + if len(v.Warnings) > 0 { + flags = append(flags, strings.Join(v.Warnings, ",")) + } + suffix := "" + if len(flags) > 0 { + suffix = " [" + strings.Join(flags, "; ") + "]" + } + fmt.Printf(" 账号 %-4d %-24s 平台=%-12s U=%-8s 来源=%-19s %s%s\n", + v.AccountID, v.Name, v.Platform, rate, v.RateSource, v.Class, suffix) + } + fmt.Printf(" 分类合计: 准入=%d 利润不足=%d 倍率非法=%d\n", + counts[service.ProfitPreviewClassAdmitted], + counts[service.ProfitPreviewClassRejectedThreshold], + counts[service.ProfitPreviewClassRejectedInvalidRate]) + models := make([]string, 0, len(report.RemainingByModel)) + for model := range report.RemainingByModel { + models = append(models, model) + } + sort.Strings(models) + for _, model := range models { + fmt.Printf(" 模型 %-20s 利润门准入账号: 默认D=%d 最低有效D=%d\n", + model, report.RemainingByModel[model], report.RemainingByModelMinD[model]) + } + for _, model := range modelsWithZeroRemaining(report) { + fmt.Printf(" 警告: 模型 %s 启用后利润门准入账号为 0\n", model) + } + for _, model := range modelsWithZeroRemainingUnderMinD(report) { + fmt.Printf(" 警告: 模型 %s 在最低有效D(存在低倍率用户覆盖)下利润门准入账号为 0\n", model) + } + } +} + +func parsePreviewInputs(raw []byte, assumeEnabled bool) ([]service.ProfitPreviewGroupInput, error) { + var doc inputDoc + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, err + } + if len(doc.Groups) == 0 { + return nil, fmt.Errorf("input contains no groups; check the export query and target configuration") + } + + inputs := make([]service.ProfitPreviewGroupInput, 0, len(doc.Groups)) + for i, entry := range doc.Groups { + if entry.Group.ID <= 0 || strings.TrimSpace(entry.Group.Platform) == "" { + return nil, fmt.Errorf("invalid group at index %d: id and platform are required", i) + } + group := &service.Group{ + ID: entry.Group.ID, + Name: entry.Group.Name, + Platform: entry.Group.Platform, + Status: service.StatusActive, + Hydrated: true, + RateMultiplier: entry.Group.RateMultiplier, + SubscriptionType: entry.Group.SubscriptionType, + ProfitControlEnabled: entry.Group.ProfitControlEnabled, + ProfitMinMargin: entry.Group.ProfitMinMargin, + ProfitSafetyBuffer: entry.Group.ProfitSafetyBuffer, + PeakRateEnabled: entry.Group.PeakRateEnabled, + PeakStart: entry.Group.PeakStart, + PeakEnd: entry.Group.PeakEnd, + PeakRateMultiplier: entry.Group.PeakRateMultiplier, + } + accounts := make([]*service.Account, 0, len(entry.Accounts)) + for _, a := range entry.Accounts { + account := &service.Account{ + ID: a.ID, + Name: a.Name, + Platform: a.Platform, + Type: a.Type, + RateMultiplier: a.RateMultiplier, + Extra: a.Extra, + } + if len(a.ModelMapping) > 0 { + mapping := make(map[string]any, len(a.ModelMapping)) + for k, v := range a.ModelMapping { + mapping[k] = v + } + account.Credentials = map[string]any{"model_mapping": mapping} + } + accounts = append(accounts, account) + } + overrides := make(map[int64]float64, len(entry.UserOverrides)) + for userID, rate := range entry.UserOverrides { + if rate == nil { + continue + } + var id int64 + if _, err := fmt.Sscan(userID, &id); err == nil && id > 0 { + overrides[id] = *rate + } + } + inputs = append(inputs, service.ProfitPreviewGroupInput{ + Group: group, + Accounts: accounts, + UserOverrides: overrides, + Models: entry.Models, + AssumeEnabled: assumeEnabled, + }) + } + return inputs, nil +} + +func modelsWithZeroRemaining(report service.ProfitPreviewGroupReport) []string { + var out []string + for model, count := range report.RemainingByModel { + if count == 0 { + out = append(out, model) + } + } + sort.Strings(out) + return out +} + +// modelsWithZeroRemainingUnderMinD 返回默认 D 下仍有准入账号、但在最低有效 D +// 下会归零的模型。最低有效 D 来自分组内最低的用户级倍率覆盖:这些模型对那部分 +// 用户是全黑的,而只看默认 D 的告警完全看不出来。 +// 两档都为 0 的模型由 modelsWithZeroRemaining 报告,这里不重复。 +func modelsWithZeroRemainingUnderMinD(report service.ProfitPreviewGroupReport) []string { + var out []string + for model, count := range report.RemainingByModelMinD { + if count == 0 && report.RemainingByModel[model] > 0 { + out = append(out, model) + } + } + sort.Strings(out) + return out +} diff --git a/backend/cmd/profit-preview/main_test.go b/backend/cmd/profit-preview/main_test.go new file mode 100644 index 000000000000..9b6d185c5b01 --- /dev/null +++ b/backend/cmd/profit-preview/main_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestParsePreviewInputsIgnoresNullUserOverride(t *testing.T) { + raw := []byte(`{ + "groups": [{ + "group": { + "id": 50, + "name": "preview", + "platform": "openai", + "rate_multiplier": 0.5, + "subscription_type": "standard", + "profit_control_enabled": false, + "profit_min_margin": 0.1, + "profit_safety_buffer": 0 + }, + "accounts": [{ + "id": 1, + "name": "cheap", + "platform": "openai", + "type": "apikey", + "rate_multiplier": 0.2 + }], + "user_overrides": {"40": null, "41": 0.4}, + "models": ["gpt-test"] + }] + }`) + + inputs, err := parsePreviewInputs(raw, true) + require.NoError(t, err) + require.Len(t, inputs, 1) + require.Equal(t, map[int64]float64{41: 0.4}, inputs[0].UserOverrides) + require.True(t, inputs[0].AssumeEnabled) + + report := service.PreviewProfitAdmission(inputs, time.Date(2026, 1, 15, 8, 30, 0, 0, time.UTC))[0] + require.InDelta(t, 0.4, report.MinEffectiveD, 1e-12, "null 覆盖不能被解码成 0 倍率") + require.InDelta(t, 0.36, report.ThresholdMinD, 1e-12) +} + +func TestParsePreviewInputsRejectsEmptyGroups(t *testing.T) { + for _, raw := range [][]byte{ + []byte(`{"groups":null}`), + []byte(`{"groups":[]}`), + } { + inputs, err := parsePreviewInputs(raw, false) + require.ErrorContains(t, err, "input contains no groups") + require.Nil(t, inputs) + } +} + +// TestModelsWithZeroRemainingWarnings 钉死两档 D 的归零告警分工: +// 默认 D 归零由 modelsWithZeroRemaining 报告;默认 D 仍有账号但最低有效 D +// (分组内存在更低的用户级倍率覆盖)归零的模型必须单独告警——那些用户的该 +// 模型会全黑,只看默认 D 完全看不出来。两档都为 0 时不重复告警。 +func TestModelsWithZeroRemainingWarnings(t *testing.T) { + report := service.ProfitPreviewGroupReport{ + RemainingByModel: map[string]int{ + "both-zero": 0, + "min-d-zero": 2, + "healthy": 3, + "min-d-zero-alt": 1, + }, + RemainingByModelMinD: map[string]int{ + "both-zero": 0, + "min-d-zero": 0, + "healthy": 3, + "min-d-zero-alt": 0, + }, + } + + if got := modelsWithZeroRemaining(report); len(got) != 1 || got[0] != "both-zero" { + t.Fatalf("默认D归零告警应只覆盖 both-zero,got %v", got) + } + + got := modelsWithZeroRemainingUnderMinD(report) + want := []string{"min-d-zero", "min-d-zero-alt"} + if len(got) != len(want) { + t.Fatalf("最低有效D归零告警不符: got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("最低有效D归零告警不符(应按模型名排序): got %v want %v", got, want) + } + } +} diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index 6e42d0859a4d..66f1d0074383 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -266,44 +266,6 @@ func TestGroupHandlerEndpoints(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code) } -func TestGroupHandlerRejectsLegacyProfitControlWrites(t *testing.T) { - router, _ := setupAdminRouter() - tests := []struct { - method string - path string - body map[string]any - }{ - { - method: http.MethodPost, - path: "/api/v1/admin/groups", - body: map[string]any{ - "name": "legacy-profit-create", "platform": "openai", "profit_control_enabled": false, - }, - }, - { - method: http.MethodPut, - path: "/api/v1/admin/groups/2", - body: map[string]any{"name": "legacy-profit-update", "profit_min_margin": 0}, - }, - { - method: http.MethodPut, - path: "/api/v1/admin/groups/2", - body: map[string]any{"name": "legacy-profit-null", "profit_safety_buffer": nil}, - }, - } - - for _, tt := range tests { - payload, err := json.Marshal(tt.body) - require.NoError(t, err) - rec := httptest.NewRecorder() - req := httptest.NewRequest(tt.method, tt.path, bytes.NewReader(payload)) - req.Header.Set("Content-Type", "application/json") - router.ServeHTTP(rec, req) - require.Equal(t, http.StatusBadRequest, rec.Code) - require.Contains(t, rec.Body.String(), "利润控制不再支持") - } -} - func TestProxyHandlerEndpoints(t *testing.T) { router, _ := setupAdminRouter() diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index fd0334a803ec..774a00f88273 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -206,9 +206,9 @@ type CreateGroupRequest struct { PeakStart string `json:"peak_start"` PeakEnd string `json:"peak_end"` PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` - ProfitControlEnabled json.RawMessage `json:"profit_control_enabled"` - ProfitMinMargin json.RawMessage `json:"profit_min_margin"` - ProfitSafetyBuffer json.RawMessage `json:"profit_safety_buffer"` + ProfitControlEnabled bool `json:"profit_control_enabled"` + ProfitMinMargin *float64 `json:"profit_min_margin"` + ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"` ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` @@ -281,9 +281,9 @@ type UpdateGroupRequest struct { PeakStart *string `json:"peak_start"` PeakEnd *string `json:"peak_end"` PeakRateMultiplier *float64 `json:"peak_rate_multiplier"` - ProfitControlEnabled json.RawMessage `json:"profit_control_enabled"` - ProfitMinMargin json.RawMessage `json:"profit_min_margin"` - ProfitSafetyBuffer json.RawMessage `json:"profit_safety_buffer"` + ProfitControlEnabled *bool `json:"profit_control_enabled"` + ProfitMinMargin *float64 `json:"profit_min_margin"` + ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"` ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` @@ -654,8 +654,10 @@ func (h *GroupHandler) Create(c *gin.Context) { return } - if len(req.ProfitControlEnabled) > 0 || len(req.ProfitMinMargin) > 0 || len(req.ProfitSafetyBuffer) > 0 { - response.BadRequest(c, "利润控制不再支持") + // platform 是 omitempty:预校验必须用与 CreateGroup 落库一致的归一化平台, + // 否则省略 platform 的请求会被误判成「平台不支持利润控制」。 + if err := service.ValidateProfitControlConfig(service.NormalizeGroupPlatform(req.Platform), req.ProfitControlEnabled, float64ValueOrDefault(req.ProfitMinMargin, 0), float64ValueOrDefault(req.ProfitSafetyBuffer, 0)); err != nil { + response.BadRequest(c, err.Error()) return } @@ -683,6 +685,9 @@ func (h *GroupHandler) Create(c *gin.Context) { PeakStart: req.PeakStart, PeakEnd: req.PeakEnd, PeakRateMultiplier: req.PeakRateMultiplier, + ProfitControlEnabled: req.ProfitControlEnabled, + ProfitMinMargin: req.ProfitMinMargin, + ProfitSafetyBuffer: req.ProfitSafetyBuffer, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, @@ -797,10 +802,6 @@ func (h *GroupHandler) Update(c *gin.Context) { response.BadRequest(c, "Invalid request: "+err.Error()) return } - if len(req.ProfitControlEnabled) > 0 || len(req.ProfitMinMargin) > 0 || len(req.ProfitSafetyBuffer) > 0 { - response.BadRequest(c, "利润控制不再支持") - return - } if h.isSimpleMode() { sanitizeUpdateGroupRequestForSimpleMode(&req) } @@ -830,6 +831,9 @@ func (h *GroupHandler) Update(c *gin.Context) { PeakStart: req.PeakStart, PeakEnd: req.PeakEnd, PeakRateMultiplier: req.PeakRateMultiplier, + ProfitControlEnabled: req.ProfitControlEnabled, + ProfitMinMargin: req.ProfitMinMargin, + ProfitSafetyBuffer: req.ProfitSafetyBuffer, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, diff --git a/backend/internal/handler/dto/group_mapper_profit_visibility_test.go b/backend/internal/handler/dto/group_mapper_profit_visibility_test.go index 4e1080c98bd0..a3a9c5a83f2f 100644 --- a/backend/internal/handler/dto/group_mapper_profit_visibility_test.go +++ b/backend/internal/handler/dto/group_mapper_profit_visibility_test.go @@ -60,12 +60,11 @@ func TestGroupFromServiceOmitsProfitControl(t *testing.T) { } } -// TestGroupFromServiceAdminReturnsStoredProfitControlFields shows administrators -// the stored legacy values of the retired profit control, read-only. -func TestGroupFromServiceAdminReturnsStoredProfitControlFields(t *testing.T) { +// TestGroupFromServiceAdminIncludesProfitControl 钉死管理端仍能读写利润控制配置。 +func TestGroupFromServiceAdminIncludesProfitControl(t *testing.T) { admin := GroupFromServiceAdmin(profitControlServiceGroup()) - if !admin.ProfitControlEnabled || admin.ProfitMinMargin != 0.3 || admin.ProfitSafetyBuffer != 0.05 { - t.Fatalf("管理员 DTO 必须原样返回库里的利润控制旧值: %+v", admin) + if admin.ProfitControlEnabled != true || admin.ProfitMinMargin != 0.3 || admin.ProfitSafetyBuffer != 0.05 { + t.Fatalf("管理员 DTO 未透传利润控制配置: %+v", admin) } fields := marshalToMap(t, admin) for _, f := range profitControlJSONFields { diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 7e3d6f9f1d5c..2647ce3374d1 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -150,8 +150,6 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { if g == nil { return nil } - // Profit control is retired and has no runtime effect; the stored legacy - // values are returned read-only (writes are rejected). out := &AdminGroup{ Group: groupFromServiceBase(g), ForceOpenAIFast: g.ForceOpenAIFast, diff --git a/backend/internal/handler/failover_loop_profit_veto_test.go b/backend/internal/handler/failover_loop_profit_veto_test.go new file mode 100644 index 000000000000..c417c27e1042 --- /dev/null +++ b/backend/internal/handler/failover_loop_profit_veto_test.go @@ -0,0 +1,139 @@ +package handler + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// profitVetoLoopResult 记录一次模拟选号循环的终止方式与步数。 +type profitVetoLoopResult struct { + outcome string // "forwarded" | "exhausted" | "budget_exceeded" + forwardedID int64 + iterations int + backoffRetries int +} + +// runProfitVetoLoop 模拟 handler 的「选号 → 利润终检 → 重选」循环,只保留与 +// 活锁相关的状态机(FailoverState + 排除列表 + HandleSelectionExhausted), +// 不涉及真实调度器与上游转发。 +// +// pool 按顺序给出候选账号;vetoed 中的账号每次终检都被利润门否决——这对应 +// 「候选池快照与 per-account 快照短暂不一致」时门的确定性判定。 +// maxIterations 是测试自身的预算:循环超过它即视为活锁。 +func runProfitVetoLoop(t *testing.T, fs *FailoverState, pool []int64, vetoed map[int64]bool, maxIterations int) profitVetoLoopResult { + t.Helper() + res := profitVetoLoopResult{} + for res.iterations = 1; res.iterations <= maxIterations; res.iterations++ { + // 选号:返回第一个不在排除列表中的账号。 + var picked int64 + for _, id := range pool { + if _, excluded := fs.FailedAccountIDs[id]; !excluded { + picked = id + break + } + } + if picked == 0 { + // 选号耗尽,交给退避决策。 + switch fs.HandleSelectionExhausted(context.Background()) { + case FailoverContinue: + res.backoffRetries++ + continue + default: + res.outcome = "exhausted" + return res + } + } + if vetoed[picked] { + if fs.RecordProfitVeto(picked) == FailoverExhausted { + res.outcome = "exhausted" + return res + } + continue + } + res.outcome = "forwarded" + res.forwardedID = picked + return res + } + res.outcome = "budget_exceeded" + return res +} + +// TestProfitVetoAfter503DoesNotLivelock 钉死 #4925 引入的活锁回归: +// 一次真实 503 之后,若调度器持续返回会被利润门否决的账号, +// 「选号 → 否决 → 排除 → 选号耗尽 → 清空排除 → 睡 2s → 选号」会无限循环, +// 因为利润否决不推进 SwitchCount,退避条件永远成立。 +func TestProfitVetoAfter503DoesNotLivelock(t *testing.T) { + fs := NewFailoverState(10, false) + // 已经历一次真实 503(Antigravity 单账号分组 MODEL_CAPACITY_EXHAUSTED 是设计内路径)。 + fs.LastFailoverErr = newTestFailoverErr(503, false, false) + fs.SwitchCount = 1 + fs.FailedAccountIDs[1] = struct{}{} + + start := time.Now() + res := runProfitVetoLoop(t, fs, []int64{1}, map[int64]bool{1: true}, 50) + elapsed := time.Since(start) + + require.Equal(t, "exhausted", res.outcome, "整池被利润门否决时必须有限步终止") + require.LessOrEqual(t, res.backoffRetries, 1, "被否决的账号不得被退避分支复活并反复重选") + require.Less(t, elapsed, 10*time.Second, "不得每 2s 空转一轮") +} + +// TestProfitVetoKeepsBackoffUsefulForHealthyAccount 钉死修复没有削弱既有的 +// 503 退避语义:排除列表里仍有非利润否决的账号时,退避照常清空并重试, +// 只是被利润门否决的账号不再复活。 +func TestProfitVetoKeepsBackoffUsefulForHealthyAccount(t *testing.T) { + fs := NewFailoverState(10, false) + fs.LastFailoverErr = newTestFailoverErr(503, false, false) + fs.SwitchCount = 1 + // 账号 1 因真实 503 被排除;账号 2 会被利润门否决。 + fs.FailedAccountIDs[1] = struct{}{} + + res := runProfitVetoLoop(t, fs, []int64{2, 1}, map[int64]bool{2: true}, 50) + + require.Equal(t, "forwarded", res.outcome) + require.Equal(t, int64(1), res.forwardedID, "退避清空后应重新可选账号 1") + require.Equal(t, 1, res.backoffRetries, "应发生且只发生一次退避重试") + require.Contains(t, fs.FailedAccountIDs, int64(2), "利润否决的账号在退避清空后必须被放回排除集") +} + +// TestProfitVetoAttemptsCapped 钉死没有 503 参与时,大分组整池越线也会在 +// 常数步内终止,而不是把整池逐个选一遍。 +func TestProfitVetoAttemptsCapped(t *testing.T) { + fs := NewFailoverState(10, false) + pool := make([]int64, 0, 64) + vetoed := make(map[int64]bool, 64) + for id := int64(1); id <= 64; id++ { + pool = append(pool, id) + vetoed[id] = true + } + + res := runProfitVetoLoop(t, fs, pool, vetoed, 200) + + require.Equal(t, "exhausted", res.outcome) + require.Equal(t, maxProfitVetoAttempts, fs.ProfitVetoCount()) + require.Equal(t, maxProfitVetoAttempts, res.iterations, "达到上限即终止,不应继续遍历候选池") +} + +// TestRecordProfitVetoExcludesAccount 钉死 RecordProfitVeto 仍然把账号加入 +// 调度排除列表(选号入参用的就是 FailedAccountIDs)。 +func TestRecordProfitVetoExcludesAccount(t *testing.T) { + fs := NewFailoverState(10, false) + require.Equal(t, FailoverContinue, fs.RecordProfitVeto(42)) + require.Contains(t, fs.FailedAccountIDs, int64(42)) + require.Equal(t, 1, fs.ProfitVetoCount()) +} + +// TestHandleSelectionExhaustedUnaffectedWithoutProfitVeto 钉死未启用利润控制的 +// 请求(无任何利润否决)走的仍是原有退避语义:清空排除列表并重试。 +func TestHandleSelectionExhaustedUnaffectedWithoutProfitVeto(t *testing.T) { + fs := NewFailoverState(3, false) + fs.LastFailoverErr = newTestFailoverErr(503, false, false) + fs.SwitchCount = 1 + fs.FailedAccountIDs[100] = struct{}{} + + require.Equal(t, FailoverContinue, fs.HandleSelectionExhausted(context.Background())) + require.Empty(t, fs.FailedAccountIDs, "无利润否决时排除列表应被完全清空") +} diff --git a/backend/internal/handler/openai_profit_slot_recheck_test.go b/backend/internal/handler/openai_profit_slot_recheck_test.go index 9bdd83e2483e..31e251c08a99 100644 --- a/backend/internal/handler/openai_profit_slot_recheck_test.go +++ b/backend/internal/handler/openai_profit_slot_recheck_test.go @@ -2,8 +2,9 @@ package handler -// 槽位准入与请求能力映射回归(handler 半程): -// - 历史利润设置不再否决账号;获取的槽位保留到调用方释放,且不写响应; +// 槽位终检与生图跳门回归(handler 半程): +// - 槽位获取成功后的利润终检:越线账号释放槽位并要求调用方排除重选, +// 不写响应、不绑定粘连; // - openAIResponsesRequiredCapability 的请求能力映射覆盖生图与原生远程压缩。 import ( @@ -95,7 +96,7 @@ func TestAcquireResponsesAccountSlotProfitRecheck(t *testing.T) { } } - t.Run("retired profit control preserves acquired slot without writing response", func(t *testing.T) { + t.Run("veto releases slot and requests reschedule without writing response", func(t *testing.T) { cache := &profitCountingConcurrencyCache{} h := newHandler(cache) w := httptest.NewRecorder() @@ -104,13 +105,10 @@ func TestAcquireResponsesAccountSlotProfitRecheck(t *testing.T) { streamStarted := false release, result := h.acquireResponsesAccountSlot(c, &groupID, "", newSelection(profitSlotTestAccount(1, 0.8)), false, &streamStarted, zap.NewNop()) - require.Equal(t, openAISlotAcquireOK, result, "已退役利润门不得因历史配置否决账号") - require.NotNil(t, release) - t.Cleanup(release) - require.Zero(t, w.Body.Len(), "正常槽位准入不得写出任何响应") - require.Zero(t, cache.accountReleases.Load(), "已获取的槽位应保留给调用方") - release() - require.Equal(t, int64(1), cache.accountReleases.Load(), "调用方结束使用后应释放一次槽位") + require.Equal(t, openAISlotAcquireProfitVetoed, result) + require.Nil(t, release) + require.Zero(t, w.Body.Len(), "利润终检否决不得写出任何响应") + require.Equal(t, int64(1), cache.accountReleases.Load(), "否决后必须立即释放已获取的槽位") }) t.Run("qualifying account acquires normally", func(t *testing.T) { diff --git a/backend/internal/handler/openai_profit_veto_budget_test.go b/backend/internal/handler/openai_profit_veto_budget_test.go new file mode 100644 index 000000000000..60e487112181 --- /dev/null +++ b/backend/internal/handler/openai_profit_veto_budget_test.go @@ -0,0 +1,74 @@ +package handler + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRecordOpenAIProfitVetoBounded 钉死 OpenAI 侧选号循环的利润否决预算: +// 账号被加入排除集,且第 maxProfitVetoAttempts 次否决返回 false 要求终止。 +// +// 上限是必需的:acquireResponsesAccountSlot 的 WaitPlan 分支先阻塞排队 +// (sticky 45s / fallback 30s)拿到槽位才做利润终检,无上限重选会把单次请求 +// 的延迟放大到 N × WaitPlan.Timeout。 +func TestRecordOpenAIProfitVetoBounded(t *testing.T) { + failed := make(map[int64]struct{}) + count := 0 + + for i := int64(1); i < int64(maxProfitVetoAttempts); i++ { + require.True(t, recordOpenAIProfitVeto(failed, i, &count), "第 %d 次否决应允许继续重选", i) + require.Contains(t, failed, i, "否决的账号必须进入本请求排除集") + } + + require.False(t, + recordOpenAIProfitVeto(failed, int64(maxProfitVetoAttempts), &count), + "第 %d 次否决应耗尽预算并要求终止", maxProfitVetoAttempts) + require.Equal(t, maxProfitVetoAttempts, count) + require.Len(t, failed, maxProfitVetoAttempts) +} + +// TestOpenAIProfitVetoLoopTerminates 模拟「选号 → 抢槽 → 利润终检否决 → 重选」 +// 循环:整池越线时必须在常数步内终止,而不是把候选池逐个排队一遍。 +func TestOpenAIProfitVetoLoopTerminates(t *testing.T) { + failed := make(map[int64]struct{}) + count := 0 + terminated := false + iterations := 0 + + // 候选池远大于否决上限:无上限时会对每个账号各排队一次。 + for accountID := int64(1); accountID <= 500; accountID++ { + iterations++ + if _, excluded := failed[accountID]; excluded { + continue + } + if !recordOpenAIProfitVeto(failed, accountID, &count) { + terminated = true + break + } + } + + require.True(t, terminated, "整池越线时循环必须由否决预算终止") + require.Equal(t, maxProfitVetoAttempts, iterations) +} + +// TestProfitVetoBudgetSharedWithFailoverState 钉死两条路径(FailoverState 与 +// OpenAI 独立计数器)使用同一上限语义,避免日后单边漂移。 +func TestProfitVetoBudgetSharedWithFailoverState(t *testing.T) { + fs := NewFailoverState(10, false) + failed := make(map[int64]struct{}) + count := 0 + + var fsStoppedAt, openAIStoppedAt int + for i := int64(1); i <= int64(maxProfitVetoAttempts)+5; i++ { + if fsStoppedAt == 0 && fs.RecordProfitVeto(i) == FailoverExhausted { + fsStoppedAt = fs.ProfitVetoCount() + } + if openAIStoppedAt == 0 && !recordOpenAIProfitVeto(failed, i, &count) { + openAIStoppedAt = count + } + } + + require.Equal(t, maxProfitVetoAttempts, fsStoppedAt) + require.Equal(t, fsStoppedAt, openAIStoppedAt, "两条路径的利润否决上限必须一致") +} diff --git a/backend/internal/service/admin_group_duplicate.go b/backend/internal/service/admin_group_duplicate.go index c616d6737691..859d19d6f280 100644 --- a/backend/internal/service/admin_group_duplicate.go +++ b/backend/internal/service/admin_group_duplicate.go @@ -103,6 +103,9 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group { PeakStart: source.PeakStart, PeakEnd: source.PeakEnd, PeakRateMultiplier: source.PeakRateMultiplier, + ProfitControlEnabled: source.ProfitControlEnabled, + ProfitMinMargin: source.ProfitMinMargin, + ProfitSafetyBuffer: source.ProfitSafetyBuffer, IsExclusive: source.IsExclusive, Status: duplicateGroupInactiveStatus, DuplicateOperationID: operationID, diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 0dd09332361f..d52e1468713b 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -304,7 +304,7 @@ type CreateGroupInput struct { MaxReasoningEffortOverLimit string // ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。 ReasoningEffortMappings []ReasoningEffortMapping - // 分组利润控制(margin/buffer 为小数,nil 按 0 处理) + // 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理) ProfitControlEnabled bool ProfitMinMargin *float64 ProfitSafetyBuffer *float64 diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index 29428ee36633..d87e130205fc 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -225,7 +225,7 @@ func TestAdminServiceSimpleModeNormalizesAllUnsupportedCreateFieldsDirectly(t *t AudioRealtimePricePerMin: &one, ClaudeCodeOnly: true, FallbackGroupID: &fallbackID, ModelRouting: map[string][]int64{"claude": {1}}, ModelRoutingEnabled: true, AllowMessagesDispatch: true, AllowLive: true, ForceOpenAIFast: true, RequireOAuthOnly: true, - RPMLimit: 99, MaxReasoningEffort: "high", + RPMLimit: 99, MaxReasoningEffort: "high", ProfitControlEnabled: true, ProfitMinMargin: &one, ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"claude-*"}}, CopyAccountsFromGroupIDs: []int64{2}, } @@ -264,7 +264,8 @@ func TestAdminServiceSimpleModeNormalizesAllUnsupportedUpdateFieldsDirectly(t *t AudioRealtimePricePerMin: &one, ClaudeCodeOnly: &truth, FallbackGroupID: &fallbackID, ModelRouting: map[string][]int64{"claude": {1}}, ModelRoutingEnabled: &truth, AllowMessagesDispatch: &truth, AllowLive: &truth, ForceOpenAIFast: &truth, RequireOAuthOnly: &truth, - RPMLimit: new(int), MaxReasoningEffort: ptrString("high"), + RPMLimit: new(int), MaxReasoningEffort: ptrString("high"), ProfitControlEnabled: &truth, + ProfitMinMargin: &one, ModelAllowlist: &GroupModelAllowlist{Enabled: true, Models: []string{"claude-*"}}, CopyAccountsFromGroupIDs: []int64{2}, } diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 2592ced75649..e6f40e1f8d03 100644 --- a/backend/internal/service/api_key_auth_cache_impl.go +++ b/backend/internal/service/api_key_auth_cache_impl.go @@ -14,7 +14,7 @@ import ( "github.com/dgraph-io/ristretto" ) -const apiKeyAuthSnapshotVersion = 27 // v27: migration 260 moved retired-platform groups to openai; reload pre-migration snapshots +const apiKeyAuthSnapshotVersion = 28 // v28: group profit control fields carry the stored values again; reload v27 snapshots written with constant zeros type apiKeyAuthCacheConfig struct { l1Size int @@ -432,9 +432,9 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) PeakStart: apiKey.Group.PeakStart, PeakEnd: apiKey.Group.PeakEnd, PeakRateMultiplier: apiKey.Group.PeakRateMultiplier, - ProfitControlEnabled: false, - ProfitMinMargin: 0, - ProfitSafetyBuffer: 0, + ProfitControlEnabled: apiKey.Group.ProfitControlEnabled, + ProfitMinMargin: apiKey.Group.ProfitMinMargin, + ProfitSafetyBuffer: apiKey.Group.ProfitSafetyBuffer, } } return snapshot @@ -534,9 +534,9 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho PeakStart: snapshot.Group.PeakStart, PeakEnd: snapshot.Group.PeakEnd, PeakRateMultiplier: snapshot.Group.PeakRateMultiplier, - ProfitControlEnabled: false, - ProfitMinMargin: 0, - ProfitSafetyBuffer: 0, + ProfitControlEnabled: snapshot.Group.ProfitControlEnabled, + ProfitMinMargin: snapshot.Group.ProfitMinMargin, + ProfitSafetyBuffer: snapshot.Group.ProfitSafetyBuffer, } } s.compileAPIKeyIPRules(apiKey) diff --git a/backend/internal/service/api_key_auth_cache_profit_test.go b/backend/internal/service/api_key_auth_cache_profit_test.go index 92dc02f8f43b..6ce7ecfce623 100644 --- a/backend/internal/service/api_key_auth_cache_profit_test.go +++ b/backend/internal/service/api_key_auth_cache_profit_test.go @@ -1,13 +1,16 @@ package service -// Historical profit-control fields remain in the cache schema for backward -// compatibility, but new snapshots materialize them as dormant zero values. +// 投影漏列回归(service 半程):认证快照 build → L2 JSON 序列化 +// → 反序列化 → 还原 apiKey.Group → 请求 ctx → 利润门解析,全链路保真。 +// repository 半程(真实 GetByKeyForAuth 投影)见 +// internal/repository/api_key_repo_profit_projection_integration_test.go。 import ( "context" "encoding/json" "testing" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/stretchr/testify/require" ) @@ -41,7 +44,9 @@ func profitAuthTestAPIKey() *APIKey { } } -func TestAPIKeyAuthSnapshotDormantProfitControlRoundtrip(t *testing.T) { +// 快照构建 → L2 JSON 往返 → 还原 → 装门:利润字段必须全程保真,阈值与 +// 计费同源(0.06 × (1−0.25) = 0.045)。 +func TestAPIKeyAuthSnapshotProfitControlRoundtrip(t *testing.T) { svc := &APIKeyService{} apiKey := profitAuthTestAPIKey() @@ -61,10 +66,17 @@ func TestAPIKeyAuthSnapshotDormantProfitControlRoundtrip(t *testing.T) { require.True(t, used) require.NotNil(t, materialized.Group) require.True(t, materialized.Group.Hydrated) - require.False(t, materialized.Group.ProfitControlEnabled) - require.Zero(t, materialized.Group.ProfitMinMargin) - require.Zero(t, materialized.Group.ProfitSafetyBuffer) + require.True(t, materialized.Group.ProfitControlEnabled) + require.InDelta(t, 0.2, materialized.Group.ProfitMinMargin, 1e-12) + require.InDelta(t, 0.05, materialized.Group.ProfitSafetyBuffer, 1e-12) require.InDelta(t, 0.06, materialized.Group.RateMultiplier, 1e-12) + + // 中间件语义:materialized.Group 进请求 ctx → 门必须按快照配置装上。 + ctx := context.WithValue(context.Background(), ctxkey.Group, materialized.Group) + gwSvc := &OpenAIGatewayService{} + gate := gwSvc.resolveOpenAIProfitControlGate(ctx, materialized.GroupID) + require.NotNil(t, gate, "还原后的认证分组必须能装门(投影漏列时本断言最先失败)") + require.InDelta(t, 0.06*(1-0.25), gate.threshold, 1e-12) } // 旧版本快照(v16 及更早,无利润字段保真保证)必须被淘汰回源,不得复用。 diff --git a/backend/internal/service/gateway_profit_control.go b/backend/internal/service/gateway_profit_control.go index 862e007e90f7..fe8ba1475253 100644 --- a/backend/internal/service/gateway_profit_control.go +++ b/backend/internal/service/gateway_profit_control.go @@ -11,9 +11,6 @@ import ( // token requests. This keeps media, metadata, and models-list paths outside // the profit-control surface by construction. func (s *GatewayService) withGatewayProfitControlGate(ctx context.Context, groupID *int64) context.Context { - if !profitControlRuntimeEnabled { - return withoutRuntimeProfitControlGate(ctx) - } if _, ok := gatewayTokenRequestPricingAtFromContext(ctx); !ok || groupID == nil || *groupID <= 0 { return ctx } diff --git a/backend/internal/service/gateway_profit_control_v2_test.go b/backend/internal/service/gateway_profit_control_v2_test.go index 53cca6f9e212..c05f4501e167 100644 --- a/backend/internal/service/gateway_profit_control_v2_test.go +++ b/backend/internal/service/gateway_profit_control_v2_test.go @@ -49,7 +49,7 @@ func gatewayProfitTestAccount(id int64, platform string, rate float64, groupID i } } -func TestGatewayDormantProfitControlKeepsFivePlatformPricing(t *testing.T) { +func TestGatewayProfitControlInstallsForFivePlatformsOnlyOnTokenRequests(t *testing.T) { for _, platform := range []string{ PlatformOpenAI, PlatformAnthropic, @@ -62,20 +62,11 @@ func TestGatewayDormantProfitControlKeepsFivePlatformPricing(t *testing.T) { groupID := group.ID svc := &GatewayService{} - base := gatewayProfitTestContext(group) - pricingAt, ok := gatewayTokenRequestPricingAtFromContext(base) - require.True(t, ok) - tokenCtx := svc.withGatewayProfitControlGate(base, &groupID) + tokenCtx := svc.withGatewayProfitControlGate(gatewayProfitTestContext(group), &groupID) gate, _ := tokenCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, gate) - actual, ok := gatewayTokenRequestPricingAtFromContext(tokenCtx) - require.True(t, ok) - require.Equal(t, pricingAt, actual) - require.False(t, pricingAt.IsZero()) - require.True(t, group.ProfitControlEnabled) - require.Equal(t, 0.5, group.RateMultiplier) - expensive := gatewayProfitTestAccount(102, platform, 5, groupID) - require.True(t, svc.isGatewayAccountProfitEligible(tokenCtx, &expensive)) + require.NotNil(t, gate) + require.Equal(t, platform, gate.platform) + require.InDelta(t, 0.5, gate.threshold, 1e-12) metadataCtx := context.WithValue(context.Background(), ctxkey.Group, group) metadataCtx = svc.withGatewayProfitControlGate(metadataCtx, &groupID) @@ -85,7 +76,7 @@ func TestGatewayDormantProfitControlKeepsFivePlatformPricing(t *testing.T) { } } -func TestGatewayDormantProfitControlPreservesCompositeBilling(t *testing.T) { +func TestGatewayProfitControlCompositeBillingUsesScheduledMemberConfig(t *testing.T) { billingGroup := &Group{ ID: 201, Platform: PlatformComposite, @@ -111,13 +102,11 @@ func TestGatewayDormantProfitControlPreservesCompositeBilling(t *testing.T) { } ctx = svc.withGatewayProfitControlGate(ctx, &memberGroup.ID) gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, gate) - actual, ok := gatewayTokenRequestPricingAtFromContext(ctx) - require.True(t, ok) - require.Equal(t, pricingAt, actual) - require.Equal(t, 0.4, billingGroup.RateMultiplier) - require.Equal(t, 99.0, memberGroup.RateMultiplier) - require.Equal(t, 0.25, memberGroup.ProfitMinMargin) + require.NotNil(t, gate) + require.Equal(t, memberGroup.ID, gate.groupID) + require.Equal(t, PlatformAnthropic, gate.platform) + require.Equal(t, pricingAt, gate.pricingAt) + require.InDelta(t, 0.4*(1-0.25), gate.threshold, 1e-12, "D 必须取 composite 计费父分组,margin 取被调度成员分组") } func TestGatewayProfitControlGroupLoadFailureClearsForeignGate(t *testing.T) { @@ -168,7 +157,7 @@ func (profitControlFailingGroupRepo) GetByID(context.Context, int64) (*Group, er panic("profit control gate must read groups via GetByIDLite (no account-count aggregation)") } -func TestGatewayDormantProfitControlKeepsLegacyMixedAndRoutedSelection(t *testing.T) { +func TestGatewayProfitControlLegacyMixedAndRoutedSelection(t *testing.T) { t.Run("legacy single-platform selection", func(t *testing.T) { group := gatewayProfitTestGroup(111, PlatformGrok) cheap := gatewayProfitTestAccount(1, PlatformGrok, 0.2, group.ID) @@ -187,16 +176,16 @@ func TestGatewayDormantProfitControlKeepsLegacyMixedAndRoutedSelection(t *testin gatewayProfitTestContext(group), &group.ID, "", "", nil, ) require.NoError(t, err) - require.Contains(t, []int64{cheap.ID, expensive.ID}, selected.ID) + require.Equal(t, cheap.ID, selected.ID) - selected, err = svc.SelectAccountForModelWithExclusions( + _, err = svc.SelectAccountForModelWithExclusions( gatewayProfitTestContext(group), &group.ID, "", "", map[int64]struct{}{cheap.ID: {}}, ) - require.NoError(t, err) - require.Equal(t, expensive.ID, selected.ID, "explicitly excluded cheap account must not return") + require.Error(t, err) + require.ErrorIs(t, err, ErrNoAvailableAccounts) }) - t.Run("mixed routing retains its model-routed account", func(t *testing.T) { + t.Run("mixed routing filters the routed account", func(t *testing.T) { group := gatewayProfitTestGroup(112, PlatformAnthropic) group.ModelRoutingEnabled = true group.ModelRouting = map[string][]int64{"claude-test": {2, 1}} @@ -218,16 +207,11 @@ func TestGatewayDormantProfitControlKeepsLegacyMixedAndRoutedSelection(t *testin gatewayProfitTestContext(group), &group.ID, "", "claude-test", nil, ) require.NoError(t, err) - require.Contains(t, []int64{cheap.ID, expensive.ID}, selected.ID) - selected, err = svc.SelectAccountForModelWithExclusions( - gatewayProfitTestContext(group), &group.ID, "", "claude-test", map[int64]struct{}{cheap.ID: {}}, - ) - require.NoError(t, err) - require.Equal(t, expensive.ID, selected.ID) + require.Equal(t, cheap.ID, selected.ID) }) } -func TestGatewayDormantProfitControlKeepsLoadAwareSelectionAndFailover(t *testing.T) { +func TestGatewayProfitControlLoadAwareSelectionAndFailover(t *testing.T) { group := gatewayProfitTestGroup(121, PlatformGrok) cheap := gatewayProfitTestAccount(1, PlatformGrok, 0.2, group.ID) expensive := gatewayProfitTestAccount(2, PlatformGrok, 0.8, group.ID) @@ -249,8 +233,7 @@ func TestGatewayDormantProfitControlKeepsLoadAwareSelectionAndFailover(t *testin ) require.NoError(t, err) require.NotNil(t, result) - require.Contains(t, []int64{cheap.ID, expensive.ID}, result.Account.ID) - require.False(t, result.ProfitGateActive()) + require.Equal(t, cheap.ID, result.Account.ID) if result.ReleaseFunc != nil { result.ReleaseFunc() } @@ -264,16 +247,12 @@ func TestGatewayDormantProfitControlKeepsLoadAwareSelectionAndFailover(t *testin "", 0, ) - require.NoError(t, err) - require.NotNil(t, result) - require.Equal(t, expensive.ID, result.Account.ID) - require.False(t, result.ProfitGateActive()) - if result.ReleaseFunc != nil { - result.ReleaseFunc() - } + require.Nil(t, result) + require.Error(t, err) + require.ErrorIs(t, err, ErrNoAvailableAccounts) } -func TestGatewayDormantProfitControlKeepsStickyBindingAcrossRateChanges(t *testing.T) { +func TestGatewayProfitControlStickyVetoKeepsBindingUntilRateRecovers(t *testing.T) { group := gatewayProfitTestGroup(131, PlatformAnthropic) expensive := gatewayProfitTestAccount(1, PlatformAnthropic, 0.8, group.ID) cheap := gatewayProfitTestAccount(2, PlatformAnthropic, 0.2, group.ID) @@ -293,14 +272,14 @@ func TestGatewayDormantProfitControlKeepsStickyBindingAcrossRateChanges(t *testi selected, err := svc.SelectAccountForModelWithExclusions(ctx, &group.ID, "sticky-profit", "", nil) require.NoError(t, err) - require.Equal(t, expensive.ID, selected.ID) + require.Equal(t, cheap.ID, selected.ID) require.Equal(t, expensive.ID, cache.sessionBindings["sticky-profit"], "候选过滤不得覆盖旧粘性绑定") require.NoError(t, svc.BindStickySessionAfterProfitAdmission( svc.withGatewayProfitControlGate(ctx, &group.ID), &group.ID, "sticky-profit", - selected.ID, + cheap.ID, )) require.Equal(t, expensive.ID, cache.sessionBindings["sticky-profit"], "终检通过的 fallback 账号也不得覆盖旧绑定") require.Zero(t, cache.deletedSessions["sticky-profit"]) @@ -409,9 +388,9 @@ func TestGatewayProfitControlTerminalRefreshFailureFallsBackToSelectedObject(t * require.Empty(t, reason) } -// Runtime selection carries no retired gate to the handler, while its pricing -// context and selected account remain unchanged. -func TestGatewayDormantProfitControlSelectionKeepsUngatedHandlerContext(t *testing.T) { +// 选号结果携带门:门安装在调度栈局部 ctx 上,handler 必须经 +// ContextWithSelectionProfitGate 重放后终检与准入后绑定才可见(评审修复回归)。 +func TestGatewayProfitControlSelectionCarriesGateToHandlerContext(t *testing.T) { group := gatewayProfitTestGroup(1, PlatformAnthropic) svc := &GatewayService{} expensive := gatewayProfitTestAccount(161, PlatformAnthropic, 0.9, group.ID) @@ -419,22 +398,17 @@ func TestGatewayDormantProfitControlSelectionKeepsUngatedHandlerContext(t *testi gateCtx := svc.withGatewayProfitControlGate(gatewayProfitTestContext(group), &group.ID) selection, err := svc.newSelectionResult(gateCtx, &expensive, true, nil, nil) require.NoError(t, err) - require.False(t, selection.ProfitGateActive()) - pricingAt, ok := gatewayTokenRequestPricingAtFromContext(gateCtx) - require.True(t, ok) + require.True(t, selection.ProfitGateActive(), "选号结果必须携带调度栈内生效的门") // 修复前的缺陷形态:handler 原始 ctx 不含门,终检退化为空操作。 _, vetoed, _ := svc.GatewayProfitControlVetoLatest(context.Background(), &expensive) require.False(t, vetoed, "对照组:不重放门时终检确实看不到门") - handlerCtx := ContextWithSelectionProfitGate(gateCtx, selection) + handlerCtx := ContextWithSelectionProfitGate(context.Background(), selection) latest, vetoed, reason := svc.GatewayProfitControlVetoLatest(handlerCtx, &expensive) - require.False(t, vetoed) - require.Empty(t, reason) - require.Same(t, &expensive, latest) - actual, ok := gatewayTokenRequestPricingAtFromContext(handlerCtx) - require.True(t, ok) - require.Equal(t, pricingAt, actual) + require.True(t, vetoed, "重放门后终检必须真实生效") + require.Equal(t, openAIProfitFilterReasonThreshold, reason) + require.NotNil(t, latest) // 无门选号不携带门,重放为无操作。 plain, err := svc.newSelectionResult(context.Background(), &expensive, true, nil, nil) @@ -443,23 +417,18 @@ func TestGatewayDormantProfitControlSelectionKeepsUngatedHandlerContext(t *testi require.Equal(t, context.Background(), ContextWithSelectionProfitGate(context.Background(), plain)) } -// Image intent is independent of retired profit admission and must not erase -// the existing token-pricing context. -func TestGatewayDormantProfitControlImageIntentKeepsTokenPricing(t *testing.T) { +// 生图意图不关门(H1/H2 回归锚点):/v1/responses 混合请求即使带生图声明, +// token 定价上下文照常装配,共享门照常安装并否决越线账号。 +func TestGatewayProfitControlImageIntentDoesNotDisableGate(t *testing.T) { group := gatewayProfitTestGroup(2, PlatformAnthropic) svc := &GatewayService{} expensive := gatewayProfitTestAccount(162, PlatformAnthropic, 0.9, group.ID) ctx := gatewayProfitTestContext(group) - pricingAt, ok := gatewayTokenRequestPricingAtFromContext(ctx) - require.True(t, ok) ctx = WithOpenAIImageGenerationIntent(ctx) gateCtx := svc.withGatewayProfitControlGate(ctx, &group.ID) - require.True(t, svc.isGatewayAccountProfitEligible(gateCtx, &expensive)) - require.False(t, gatewayProfitControlGateActive(gateCtx)) - actual, ok := gatewayTokenRequestPricingAtFromContext(gateCtx) - require.True(t, ok) - require.Equal(t, pricingAt, actual) + require.False(t, svc.isGatewayAccountProfitEligible(gateCtx, &expensive), + "请求体里的生图声明(含被动 image_gen namespace)不得关闭利润门") } // 无门时准入后绑定回退官方 eager 语义;门下读失败保守不写(评审 M-Bind 回归)。 diff --git a/backend/internal/service/gateway_scheduling.go b/backend/internal/service/gateway_scheduling.go index 4798308e8113..5d497bd118f7 100644 --- a/backend/internal/service/gateway_scheduling.go +++ b/backend/internal/service/gateway_scheduling.go @@ -2424,6 +2424,8 @@ type selectionFailureStats struct { PlatformFiltered int ModelUnsupported int ModelRateLimited int + ProfitThreshold int + ProfitInvalidRate int SamplePlatformIDs []int64 SampleMappingIDs []int64 SampleRateLimitIDs []string @@ -2447,7 +2449,7 @@ func (s *GatewayService) logDetailedSelectionFailure( stats := s.collectSelectionFailureStats(ctx, accounts, requestedModel, platform, excludedIDs, allowMixedScheduling) logger.LegacyPrintf( "service.gateway", - "[SelectAccountDetailed] group_id=%v model=%s platform=%s session=%s total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d sample_platform_filtered=%v sample_model_unsupported=%v sample_model_rate_limited=%v", + "[SelectAccountDetailed] group_id=%v model=%s platform=%s session=%s total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d profit_threshold=%d profit_invalid_account_rate=%d sample_platform_filtered=%v sample_model_unsupported=%v sample_model_rate_limited=%v", derefGroupID(groupID), requestedModel, platform, @@ -2459,6 +2461,8 @@ func (s *GatewayService) logDetailedSelectionFailure( stats.PlatformFiltered, stats.ModelUnsupported, stats.ModelRateLimited, + stats.ProfitThreshold, + stats.ProfitInvalidRate, stats.SamplePlatformIDs, stats.SampleMappingIDs, stats.SampleRateLimitIDs, @@ -2496,6 +2500,10 @@ func (s *GatewayService) collectSelectionFailureStats( stats.ModelRateLimited++ remaining := acc.GetRateLimitRemainingTimeWithContext(ctx, requestedModel).Truncate(time.Second) stats.SampleRateLimitIDs = appendSelectionFailureRateSample(stats.SampleRateLimitIDs, acc.ID, remaining) + case openAIProfitFilterReasonThreshold: + stats.ProfitThreshold++ + case openAIProfitFilterReasonInvalidAccountRate: + stats.ProfitInvalidRate++ default: stats.Eligible++ } @@ -2580,7 +2588,7 @@ func appendSelectionFailureRateSample(samples []string, accountID int64, remaini func summarizeSelectionFailureStats(stats selectionFailureStats) string { return fmt.Sprintf( - "total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d", + "total=%d eligible=%d excluded=%d unschedulable=%d platform_filtered=%d model_unsupported=%d model_rate_limited=%d profit_threshold=%d profit_invalid_account_rate=%d", stats.Total, stats.Eligible, stats.Excluded, @@ -2588,6 +2596,8 @@ func summarizeSelectionFailureStats(stats selectionFailureStats) string { stats.PlatformFiltered, stats.ModelUnsupported, stats.ModelRateLimited, + stats.ProfitThreshold, + stats.ProfitInvalidRate, ) } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 9c54a5f86d41..68f39ca5d5c0 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -617,8 +617,9 @@ type AccountSelectionResult struct { runtimeBreakerProbeEnded atomic.Bool // stickySessionHit 标记账号来自会话粘性绑定命中,供非高级调度路径回填决策标签。 stickySessionHit bool - // profitGate carries the gate applied during selection so post-acquire - // eligibility checks use the same pricing decision. + // profitGate 携带本次选号真实生效的利润门(无门为 nil)。门安装在调度栈的 + // 局部 ctx 上,handler 必须经 ContextWithSelectionProfitGate 重放后才能在 + // 调度栈之外做抢槽后终检与准入后粘性绑定。 profitGate *openAIProfitControlGate } @@ -629,7 +630,7 @@ func (r *AccountSelectionResult) HasOpenAIRuntimeBreakerProbe() bool { (strings.TrimSpace(r.runtimeBreakerProbeOwner) != "" && len(r.runtimeBreakerProbeModels) > 0)) } -// ProfitGateActive reports whether this selection was made under profit control. +// ProfitGateActive 报告本次选号是否处于利润门之下。 func (r *AccountSelectionResult) ProfitGateActive() bool { return r != nil && r.profitGate != nil } @@ -1023,6 +1024,10 @@ func (s *GatewayService) BindStickySession(ctx context.Context, groupID *int64, return s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, accountID, stickySessionTTL) } +// bindGatewayStickySessionDuringSelection preserves the normal eager sticky +// behavior unless a profit gate is installed. Profit-controlled requests bind +// only after the terminal post-slot check, otherwise a rejected candidate could +// overwrite a healthy pre-existing sticky binding. func (s *GatewayService) bindGatewayStickySessionDuringSelection(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error { if gatewayProfitControlGateActive(ctx) { return nil @@ -1030,8 +1035,12 @@ func (s *GatewayService) bindGatewayStickySessionDuringSelection(ctx context.Con return s.BindStickySession(ctx, groupID, sessionHash, accountID) } -// BindStickySessionAfterProfitAdmission preserves an existing different -// binding while a profit gate is active and binds only the admitted account. +// BindStickySessionAfterProfitAdmission records a terminally admitted +// account. Without a profit gate it preserves the pre-existing eager binding +// behavior at the handler bind points. With a gate it never replaces a +// different binding that already exists: a temporarily ineligible sticky +// account remains bound and automatically becomes eligible again if its +// account rate recovers. func (s *GatewayService) BindStickySessionAfterProfitAdmission(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error { if sessionHash == "" || accountID <= 0 || s.cache == nil { return nil @@ -1041,6 +1050,7 @@ func (s *GatewayService) BindStickySessionAfterProfitAdmission(ctx context.Conte } existingAccountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash) if err != nil && !errors.Is(err, ErrStickySessionNotFound) { + // 读失败时无法判断既有绑定,保守跳过而不是冒着覆盖健康绑定的风险写入。 slog.Warn("profit_control_sticky_binding_read_failed", "group_id", derefGroupID(groupID), "account_id", accountID, "error", err) return nil } diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index 6d587f16c58b..bb6804a82307 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -400,7 +400,15 @@ func computePeakAwareMultipliers(apiKey *APIKey, base float64, now time.Time) (t return } +// validProfitControlRatio 判定 margin/buffer 是否为可落库的合法小数:[0,1) 且非 NaN/Inf。 +func validProfitControlRatio(v float64) bool { + return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v < 1 +} + // NormalizeGroupPlatform 把创建分组时省略的 platform 归一化为默认平台。 +// handler 的入参预校验必须与 CreateGroup 落库时用同一个归一化结果,否则 +// 「省略 platform + 启用利润控制」会被 handler 以「平台不支持」400 掉, +// 而该分组本会被建成受支持的 anthropic 分组。 func NormalizeGroupPlatform(platform string) string { if platform == "" { return PlatformAnthropic @@ -408,11 +416,6 @@ func NormalizeGroupPlatform(platform string) string { return platform } -// validProfitControlRatio accepts finite ratios in [0, 1). -func validProfitControlRatio(v float64) bool { - return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v < 1 -} - // ValidateProfitControlConfig 是分组利润控制配置的唯一校验来源,handler 与 service 层共用。 // enabled=true 时仅允许五个可计费平台分组;margin/buffer 各自 ∈ [0,1),且 margin+buffer < 1 // (相加 >=1 时阈值 <=0,所有可核价账号都会被排除,视为配置错误而不是静默全黑)。 diff --git a/backend/internal/service/group_profit_platform_test.go b/backend/internal/service/group_profit_platform_test.go new file mode 100644 index 000000000000..28a5f2290a7e --- /dev/null +++ b/backend/internal/service/group_profit_platform_test.go @@ -0,0 +1,20 @@ +package service + +import "testing" + +// TestNormalizeGroupPlatformDefaultsToAnthropic 钉死创建分组时省略 platform 的 +// 归一化结果:handler 的利润控制预校验与 CreateGroup 落库共用这个函数, +// 两边一旦漂移,「省略 platform + profit_control_enabled=true」的创建请求 +// 会被 handler 以「平台不支持」400 掉,而该分组本会建成受支持的 anthropic。 +func TestNormalizeGroupPlatformDefaultsToAnthropic(t *testing.T) { + if got := NormalizeGroupPlatform(""); got != PlatformAnthropic { + t.Fatalf("空 platform 应归一化为 %s,got %s", PlatformAnthropic, got) + } + if got := NormalizeGroupPlatform(PlatformOpenAI); got != PlatformOpenAI { + t.Fatalf("显式 platform 不应被改写,got %s", got) + } + // 归一化后的默认平台必须真的支持利润控制,否则预校验仍会误拒。 + if err := ValidateProfitControlConfig(NormalizeGroupPlatform(""), true, 0.3, 0.05); err != nil { + t.Fatalf("省略 platform 且启用利润控制不应被预校验拒绝: %v", err) + } +} diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index a96cb833f4ce..0f6337babc58 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -1890,6 +1890,8 @@ func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatibleReason(ctx con if !accountSupportsOpenAICapabilities(ctx, account, requestedModel, req.RequiredCapability, req.RequiredImageCapability) { return false, "capability_mismatch" } + // 分组利润控制:不合格账号在候选过滤与抢槽后终检阶段即被排除, + // 排序/评分/粘性/熔断只在合格账号之间工作;named reason 进入 filter stats。 if vetoed, reason := openAIProfitControlVetoReason(ctx, account); vetoed { return false, reason } diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go index 9f87735105c8..429d9a0e4954 100644 --- a/backend/internal/service/openai_gateway_scheduling.go +++ b/backend/internal/service/openai_gateway_scheduling.go @@ -909,8 +909,9 @@ func (s *OpenAIGatewayService) selectAccountForModelWithExclusionsStickyHit(ctx return nil, false, err } - // 4. 设置粘性会话绑定 - // Set sticky session binding + // 4. 设置粘性会话绑定(利润门下推迟到 handler 终检通过后再绑定, + // 终检否决的账号不得成为新的粘性目标;无门保持既有 eager 绑定与 TTL) + // Set sticky session binding (deferred until terminal admission under a profit gate) if sessionHash != "" && !gatewayProfitControlGateActive(ctx) { _ = s.setStickySessionAccountID(ctx, groupID, sessionHash, selected.ID, openaiStickySessionTTL) } @@ -1114,6 +1115,8 @@ func (s *OpenAIGatewayService) isBetterAccount(candidate, current *Account) bool // SelectAccountWithLoadAwareness selects an account with load-awareness and wait plan. func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Context, groupID *int64, sessionHash string, requestedModel string, excludedIDs map[int64]struct{}) (*AccountSelectionResult, error) { ctx = s.withOpenAIQuotaAutoPauseContext(ctx) + // 分组利润控制:legacy 公共入口同样装门,保证不经 + // selectAccountWithScheduler 的调用方也无法绕过利润准入。 ctx = s.withOpenAIProfitControlGate(ctx, groupID) return s.selectAccountWithLoadAwareness(ctx, groupID, PlatformOpenAI, sessionHash, requestedModel, excludedIDs, false, "", true) } diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 2a747771093c..31d1d5a34888 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -33,7 +33,10 @@ type OpenAIRecordUsageInput struct { RequestPayloadHash string APIKeyService APIKeyQuotaUpdater QuotaPlatform string // user×platform quota platform resolved by the handler before async billing. - PricingAt time.Time + // PricingAt 是请求级定价时刻(请求开始捕获,与利润门的 D 同源):高峰因子 + // 按该时刻计算,保证同一请求从准入到扣费不中途变价。零值回退记录时刻 + //(既有行为),供未装配的路径(图片/异步/cyber 等)沿用。 + PricingAt time.Time // CyberBlocked 为 true 时把该用量行标记为 cyber(request_type=cyber),计费逻辑不变。 CyberBlocked bool // NativeCompactionV2 is an orthogonal semantic flag captured by the @@ -43,13 +46,6 @@ type OpenAIRecordUsageInput struct { ChannelUsageFields } -func openAIUsagePricingAt(input *OpenAIRecordUsageInput) time.Time { - if input != nil && !input.PricingAt.IsZero() { - return input.PricingAt - } - return timezone.Now() -} - // CyberPolicyUsageInput 是 cyber 拒绝、未走正常 RecordUsage 的请求记录用量的入参。 // 用量按上游真实 token 计费,与 WS cyber 及正常请求口径一致(InputTokens/OutputTokens // 取自上游 response.failed 报告的 usage,即 mark.UpstreamInTok/OutTok)。 @@ -127,6 +123,15 @@ func (s *OpenAIGatewayService) ResolveUserGroupRateMultiplier(ctx context.Contex return resolver.Resolve(ctx, userID, groupID, groupDefaultMultiplier) } +// openAIUsagePricingAt 返回本次用量记录使用的定价时刻:优先请求级 PricingAt +// (与利润门 D 同源同刻),未装配时回退记录时刻(既有行为)。 +func openAIUsagePricingAt(input *OpenAIRecordUsageInput) time.Time { + if input != nil && !input.PricingAt.IsZero() { + return input.PricingAt + } + return timezone.Now() +} + func groupBillsOpenAIFastAtStandard(apiKey *APIKey, account *Account, serviceTier string) bool { if apiKey == nil || apiKey.Group == nil || !apiKey.Group.FreeOpenAIFast { return false @@ -203,8 +208,10 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if apiKey.GroupID != nil && apiKey.Group != nil { multiplier = s.ResolveUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier) } - // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。高峰因子按请求时刻现算, - // 不并入上面的 Resolve,以免污染 user:group 倍率缓存。 + // token 倍率叠加高峰因子(token 计费含图片 token,图片按次倍率不受影响)。 + // 高峰因子按请求级 PricingAt 现算(与利润门 D 同源同刻,跨峰谷请求不中途 + // 变价);未装配 PricingAt 的路径回退记录时刻,保持既有行为。不并入上面的 + // Resolve,以免污染 user:group 倍率缓存。 baseMultiplier := multiplier pricingAt := openAIUsagePricingAt(input) multiplier, imageMultiplier := computePeakAwareMultipliers(apiKey, baseMultiplier, pricingAt) diff --git a/backend/internal/service/openai_profit_control.go b/backend/internal/service/openai_profit_control.go index 74e87d1b420c..a3418e6fcf35 100644 --- a/backend/internal/service/openai_profit_control.go +++ b/backend/internal/service/openai_profit_control.go @@ -72,10 +72,6 @@ import ( ) const ( - // Downstream retired profit admission. Retain historical schema and pure - // numerical helpers, but no configuration or stored row may enable a gate. - profitControlRuntimeEnabled = false - // profitControlRateEpsilon 吸收 decimal(10,4) 落库与浮点乘法的边界误差: // U 与阈值的相对差在该量级内视为相等(即 U == 阈值 判定为合格)。 profitControlRateEpsilon = 1e-9 @@ -91,13 +87,6 @@ const ( type openAIProfitControlGateCtxKey struct{} -func withoutRuntimeProfitControlGate(ctx context.Context) context.Context { - if gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate); gate != nil { - return context.WithValue(ctx, openAIProfitControlGateCtxKey{}, (*openAIProfitControlGate)(nil)) - } - return ctx -} - // openAIProfitControlSuppressCtxKey 标记本请求显式跳过利润门(独立图片/视频 // 端点、Grok 媒体、count_tokens、live 等利润门范围外流量)。所有装门点看到该 // 标记后一律不装门,防止 service 层防御性装门把边界外流量重新拉回利润过滤。 @@ -163,9 +152,6 @@ func WithOpenAIProfitControlSuppressed(ctx context.Context) context.Context { func (s *OpenAIGatewayService) WithOpenAITurnPricingContext(ctx context.Context, groupID *int64) (context.Context, time.Time) { pricingAt := timezone.Now() ctx = context.WithValue(ctx, openAIPricingAtCtxKey{}, pricingAt) - if !profitControlRuntimeEnabled { - return withoutRuntimeProfitControlGate(ctx), pricingAt - } if _, suppressed := ctx.Value(openAIProfitControlSuppressCtxKey{}).(struct{}); suppressed { return ctx, pricingAt } @@ -206,9 +192,6 @@ func OpenAIPricingAtFromContext(ctx context.Context) time.Time { // (门不存在,全部否决点自动放行,既有行为零变化)。ctx 已有同分组门时直接 // 复用:同一请求的全部 failover 重入共享同一阈值。 func (s *OpenAIGatewayService) withOpenAIProfitControlGate(ctx context.Context, groupID *int64) context.Context { - if !profitControlRuntimeEnabled { - return withoutRuntimeProfitControlGate(ctx) - } if _, suppressed := ctx.Value(openAIProfitControlSuppressCtxKey{}).(struct{}); suppressed { return ctx } @@ -232,9 +215,6 @@ func (s *OpenAIGatewayService) withOpenAIProfitControlGate(ctx context.Context, } func (s *OpenAIGatewayService) resolveOpenAIProfitControlGate(ctx context.Context, groupID *int64) *openAIProfitControlGate { - if !profitControlRuntimeEnabled { - return nil - } if s == nil || groupID == nil || *groupID <= 0 { return nil } diff --git a/backend/internal/service/openai_profit_control_legacy_diagnostics_test.go b/backend/internal/service/openai_profit_control_legacy_diagnostics_test.go index 53cde7f71eff..19e6b027e273 100644 --- a/backend/internal/service/openai_profit_control_legacy_diagnostics_test.go +++ b/backend/internal/service/openai_profit_control_legacy_diagnostics_test.go @@ -28,26 +28,22 @@ func legacyProfitDiagnosticAccount(id int64) *Account { return a } -func TestSelectAccountWithScheduler_DormantProfitKeepsNonProfitDiagnostics(t *testing.T) { +func TestSelectAccountWithScheduler_LegacyProfitDiagnostics(t *testing.T) { groupID := int64(5313) ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) - t.Run("legacy margin does not create a threshold rejection", func(t *testing.T) { + t.Run("threshold reports deterministic pool count", func(t *testing.T) { account := legacyProfitDiagnosticAccount(53131) profitControlTestAccountWithRate(account, 0.9) svc := legacyProfitDiagnosticService([]Account{*account}) selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) - require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, account.ID, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } + require.Nil(t, selection) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.Contains(t, err.Error(), "pool=1, filtered: profit_threshold=1") }) - t.Run("missing account rate does not activate retired validation", func(t *testing.T) { + t.Run("missing account rate reports invalid rate", func(t *testing.T) { account := upstreamCostTestOAuthAccount(53132) account.Status = StatusActive account.Schedulable = true @@ -55,13 +51,9 @@ func TestSelectAccountWithScheduler_DormantProfitKeepsNonProfitDiagnostics(t *te svc := legacyProfitDiagnosticService([]Account{*account}) selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) - require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, account.ID, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } + require.Nil(t, selection) + require.ErrorIs(t, err, ErrNoAvailableAccounts) + require.Contains(t, err.Error(), "profit_invalid_account_rate=1") }) t.Run("model support gate does not report profit reasons", func(t *testing.T) { diff --git a/backend/internal/service/openai_profit_control_paths_test.go b/backend/internal/service/openai_profit_control_paths_test.go index 2b5703a00326..239a7d26f2a1 100644 --- a/backend/internal/service/openai_profit_control_paths_test.go +++ b/backend/internal/service/openai_profit_control_paths_test.go @@ -1,9 +1,11 @@ package service -// Runtime path cases follow the downstream retirement contract: legacy profit -// fields cannot filter accounts, but explicit exclusions, model capability, -// sticky identity and billing rates remain effective. Private manually-built -// gate cases preserve the independent numerical/admission helper coverage. +// 利润控制请求路径矩阵测试:证明所有文本调度路径都经过利润准入过滤, +// 且任何 fallback 都不能把已排除账号重新放回候选。 +// 覆盖:高级调度器候选池(openai_profit_control_test.go)、legacy 引擎、 +// previous_response WSv2 粘连(跳过复用但保留绑定 + 倍率恢复重粘连)、 +// failover 排除不回收、抢槽后终检、倍率恢复重新准入、 +// 用户覆盖倍率 D、composite 计费分组与调度分组分离。 import ( "context" @@ -26,9 +28,8 @@ func profitControlWSAccount(id int64, rate float64, now time.Time) Account { return *account } -// Dormant admission never rejects the previous-response binding based on a -// saved legacy margin; both old and subsequently changed rates remain usable. -func TestProfitControl_DormantPreviousResponseKeepsBinding(t *testing.T) { +// previous_response_id 粘连:利润不合格 → 跳过复用但不删绑定;倍率恢复 → 重新粘连。 +func TestProfitControl_PreviousResponseStickyVetoKeepsBinding(t *testing.T) { ctx := profitControlTestCtx(profitControlTestGroup(23, 0.5, 0)) groupID := int64(23) now := time.Now() @@ -47,14 +48,9 @@ func TestProfitControl_DormantPreviousResponseKeepsBinding(t *testing.T) { selection, err := svc.SelectAccountByPreviousResponseID(ctx, &groupID, "resp_profit", "gpt-5.1", nil, false) require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, expensive.ID, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } + require.Nil(t, selection, "上游倍率 0.8 超过阈值 0.5 的账号不应继续命中 previous_response_id 粘连") - // Historical profit data must not invalidate the existing response binding. + // 利润不合格与 quota auto-pause 同为暂时状态:绑定必须保留。 boundAccountID, getErr := store.GetResponseAccount(ctx, groupID, "resp_profit") require.NoError(t, getErr) require.Equal(t, expensive.ID, boundAccountID) @@ -72,7 +68,7 @@ func TestProfitControl_DormantPreviousResponseKeepsBinding(t *testing.T) { } // legacy 引擎(高级调度器关闭):候选过滤、全排除错误语义与既有语义一致。 -func TestProfitControl_DormantLegacyEngineKeepsEligibleCandidates(t *testing.T) { +func TestProfitControl_LegacyEngineFiltersCandidates(t *testing.T) { now := time.Now() cheap := upstreamCostTestAccount(41, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute) expensive := upstreamCostTestAccount(42, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) @@ -91,30 +87,25 @@ func TestProfitControl_DormantLegacyEngineKeepsEligibleCandidates(t *testing.T) } groupID := int64(7) - t.Run("legacy path admits either otherwise eligible account", func(t *testing.T) { - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)), &groupID) - for _, expected := range []int64{cheap.ID, expensive.ID} { - excluded := map[int64]struct{}{cheap.ID: {}, expensive.ID: {}} - delete(excluded, expected) - selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", excluded, OpenAIUpstreamTransportAny, false) + t.Run("legacy path only admits profitable accounts", func(t *testing.T) { + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + for i := 0; i < 5; i++ { + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.NoError(t, err) require.NotNil(t, selection) - require.Equal(t, expected, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) + require.Equal(t, cheap.ID, selection.Account.ID) if selection.ReleaseFunc != nil { selection.ReleaseFunc() } } }) - t.Run("explicit exclusions still return the standard error", func(t *testing.T) { + t.Run("legacy path all excluded returns standard error", func(t *testing.T) { ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.7, 0.1)) - selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", map[int64]struct{}{cheap.ID: {}, expensive.ID: {}}, OpenAIUpstreamTransportAny, false) + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.Nil(t, selection) require.Error(t, err) require.True(t, errors.Is(err, ErrNoAvailableAccounts)) - require.NotContains(t, err.Error(), openAIProfitFilterReasonThreshold) }) t.Run("legacy path keeps official behavior when gate disabled", func(t *testing.T) { @@ -129,9 +120,8 @@ func TestProfitControl_DormantLegacyEngineKeepsEligibleCandidates(t *testing.T) }) } -// Explicit failures stay excluded; historical profit settings must not also -// exclude the remaining otherwise eligible account. -func TestProfitControl_DormantFailoverKeepsExplicitExclusions(t *testing.T) { +// failover:可盈利账号因失败被排除后,剩余不合格账号不得被"放回"候选。 +func TestProfitControl_FailoverDoesNotReadmitExcluded(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() defer resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -156,21 +146,15 @@ func TestProfitControl_DormantFailoverKeepsExplicitExclusions(t *testing.T) { concurrencyService: NewConcurrencyService(cache), } groupID := int64(7) - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)), &groupID) + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) - // Simulate a real failure exclusion; the remaining expensive account is - // still eligible because profit admission is retired. + // 模拟 failover:上一轮失败的 cheap 已进入 excludedIDs,仅剩 expensive 不合格。 excluded := map[int64]struct{}{cheap.ID: {}} selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", excluded, OpenAIUpstreamTransportAny, false) - require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, expensive.ID, selection.Account.ID) - require.Contains(t, excluded, cheap.ID) - require.False(t, selection.ProfitGateActive()) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } + require.Nil(t, selection, "failover 后不得回收利润不合格账号") + require.Error(t, err) + require.True(t, errors.Is(err, ErrNoAvailableAccounts)) + require.Contains(t, err.Error(), openAIProfitFilterReasonThreshold+"=1") } // 抢槽后终检:候选构建后才变得不合格的账号(状态竞态)在取得槽位前被拦截。 @@ -201,9 +185,8 @@ func TestProfitControl_PostSlotRecheckVetoes(t *testing.T) { require.Equal(t, cache.totalAcquires(), cache.releaseCount(expensive.ID), "被拦截账号不得泄漏并发槽位") } -// Rate changes remain observable account data but do not toggle retired -// admission; the only healthy account is selectable on both sides. -func TestProfitControl_DormantRateChangesKeepAccountEligible(t *testing.T) { +// 倍率恢复:探测刷新回落到阈值内后,此前被排除的账号重新参与调度。 +func TestProfitControl_RateRecoveryReadmitsAccount(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() defer resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -226,13 +209,8 @@ func TestProfitControl_DormantRateChangesKeepAccountEligible(t *testing.T) { ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) - require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, expensive.ID, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } + require.Nil(t, selection) + require.Error(t, err) // 同步/手工写回:账号倍率回落到 0.3(阈值 0.5 内)后自动恢复参与。 recovered := upstreamCostTestAccount(71, UpstreamBillingProbeStatusOK, 0.3, time.Now().Add(-time.Minute), 30*time.Minute) @@ -260,8 +238,8 @@ func (r profitControlUserRateRepo) GetByUserAndGroup(context.Context, int64, int return r.rate, nil } -// Retirement does not alter the ordinary user-specific billing rate resolver. -func TestProfitControl_DormantGatePreservesUserOverrideRate(t *testing.T) { +// D 必须取请求用户的真实倍率:有用户覆盖时用覆盖值,绝不退回分组默认。 +func TestProfitControl_GateUsesUserOverrideRate(t *testing.T) { override := 0.5 svc := &OpenAIGatewayService{ userGroupRateResolver: newUserGroupRateResolver( @@ -274,15 +252,13 @@ func TestProfitControl_DormantGatePreservesUserOverrideRate(t *testing.T) { ctx := context.WithValue(profitControlTestCtx(group), ctxkey.UserID, int64(42)) gate := svc.resolveOpenAIProfitControlGate(ctx, &groupID) - require.Nil(t, gate) - require.InDelta(t, 0.5, svc.ResolveUserGroupRateMultiplier(ctx, 42, groupID, group.RateMultiplier), 1e-12, - "ordinary billing still resolves the saved user override") + require.NotNil(t, gate) + require.InDelta(t, 0.5, gate.threshold, 1e-12, "阈值必须基于用户覆盖倍率 0.5,而不是分组默认 2.0") // 无用户身份(内部调用)时按分组默认倍率计算。 gate = svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) - require.Nil(t, gate) - require.Equal(t, 2.0, group.RateMultiplier) - require.Equal(t, 0.5, override) + require.NotNil(t, gate) + require.InDelta(t, 2.0, gate.threshold, 1e-12) } type profitControlGroupRepo struct { @@ -301,8 +277,8 @@ func (r profitControlGroupRepo) GetByID(context.Context, int64) (*Group, error) panic("profit control gate must read groups via GetByIDLite (no account-count aggregation)") } -// Cross-group retirement preserves both billing and scheduled-group data. -func TestProfitControl_DormantCompositePreservesBothGroupRates(t *testing.T) { +// composite 路由:门配置取被调度成员分组,D 取请求真实计费分组(ctx 认证分组)。 +func TestProfitControl_CompositeUsesBillingGroupRate(t *testing.T) { memberGroupID := int64(7) memberGroup := profitControlTestGroup(memberGroupID, 0.5, 0) memberGroup.RateMultiplier = 99 // 若 D 误取成员分组倍率,阈值会是 49.5 @@ -320,12 +296,8 @@ func TestProfitControl_DormantCompositePreservesBothGroupRates(t *testing.T) { ctx := profitControlTestCtx(billingGroup) gate := svc.resolveOpenAIProfitControlGate(ctx, &memberGroupID) - require.Nil(t, gate) - priced, pricingAt := svc.WithOpenAIRequestPricingContext(ctx, &memberGroupID) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(priced)) - require.Equal(t, 1.0, billingGroup.RateMultiplier) - require.Equal(t, 99.0, memberGroup.RateMultiplier) - require.True(t, memberGroup.ProfitControlEnabled, "retirement must not rewrite persisted legacy configuration") + require.NotNil(t, gate) + require.InDelta(t, 0.5, gate.threshold, 1e-12, "D 必须来自计费分组(composite 父分组)倍率 1.0") } // legacy 引擎与 DB recheck 共用的资格判定直接覆盖利润门。 @@ -350,9 +322,9 @@ func TestProfitControl_EligibilityFunctionVetoes(t *testing.T) { require.True(t, isOpenAICompatibleAccountEligibleForRequest(context.Background(), expensive, PlatformOpenAI, "", false, "")) } -// Legacy engine selection keeps ordinary eager binding even if persisted rows -// still carry retired profit configuration. -func TestProfitControl_DormantLegacyEngineKeepsEagerStickyBinding(t *testing.T) { +// legacy 引擎粘性写回(评审 M-Legacy 回归):门下选号阶段不得直写粘性—— +// 终检否决的账号不能成为新绑定;无门保持官方 eager 绑定与原 TTL 语义。 +func TestProfitControl_LegacyEngineDefersStickyBindingUnderGate(t *testing.T) { now := time.Now() cheap := upstreamCostTestAccount(45, UpstreamBillingProbeStatusOK, 0.3, now.Add(-time.Minute), 30*time.Minute) expensive := upstreamCostTestAccount(46, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) @@ -376,16 +348,15 @@ func TestProfitControl_DormantLegacyEngineKeepsEagerStickyBinding(t *testing.T) }, cache } - t.Run("legacy enabled values keep eager binding without a runtime gate", func(t *testing.T) { + t.Run("gated selection defers binding to terminal admission", func(t *testing.T) { svc, cache := newSvc(map[string]int64{}) ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", sessionHash, "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.NoError(t, err) require.NotNil(t, selection) - require.Contains(t, []int64{cheap.ID, expensive.ID}, selection.Account.ID) - require.Contains(t, cache.sessionBindings, "openai:"+sessionHash) - require.Equal(t, selection.Account.ID, cache.sessionBindings["openai:"+sessionHash]) - require.False(t, selection.ProfitGateActive()) + require.Equal(t, cheap.ID, selection.Account.ID) + require.Empty(t, cache.sessionBindings, "门下 legacy 选号阶段不得直写粘性绑定") + require.True(t, selection.ProfitGateActive(), "legacy 选号结果同样携带门") if selection.ReleaseFunc != nil { selection.ReleaseFunc() } diff --git a/backend/internal/service/openai_profit_control_pricing_test.go b/backend/internal/service/openai_profit_control_pricing_test.go index 726284954443..379789f20020 100644 --- a/backend/internal/service/openai_profit_control_pricing_test.go +++ b/backend/internal/service/openai_profit_control_pricing_test.go @@ -1,8 +1,8 @@ package service -// Downstream runtime boundaries keep profit admission dormant while preserving -// pricingAt, endpoint eligibility and sticky behavior. Manually constructed -// private-gate tests below still cover the unchanged numerical/usage helpers. +// 请求级定价与利润门回归:请求级 pricingAt 定价上下文、门复用(failover 阈值稳定)、 +// Responses 文本能力利润门、U 使用账号倍率且与探测新鲜度解耦、 +// 用量记录定价时刻取值。 import ( "context" @@ -14,25 +14,23 @@ import ( "github.com/stretchr/testify/require" ) -// Downstream retirement keeps request pricing but never enables admission, -// including historical enabled rows and explicitly suppressed requests. -func TestProfitControl_DormantRequestPricingContext(t *testing.T) { +// WithOpenAIRequestPricingContext:装门 + 固定 pricingAt;显式抑制标记 +// (媒体/count_tokens/live 等门范围外路径)跳门且防御性装门无法把门加回来。 +func TestProfitControl_RequestPricingContext(t *testing.T) { svc := &OpenAIGatewayService{} groupID := int64(61) now := time.Now() expensive := upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) profitControlTestAccountWithRate(expensive, 0.8) - t.Run("keeps pricing instant without admission", func(t *testing.T) { + t.Run("installs gate and pricing instant", func(t *testing.T) { base := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) ctx, pricingAt := svc.WithOpenAIRequestPricingContext(base, &groupID) require.False(t, pricingAt.IsZero()) require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) vetoed, reason := OpenAIProfitControlVeto(ctx, expensive) - require.False(t, vetoed) - require.Empty(t, reason) - require.False(t, gatewayProfitControlGateActive(ctx)) - require.Equal(t, 0.8, *expensive.RateMultiplier) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonThreshold, reason) }) t.Run("suppress marker skips gate everywhere", func(t *testing.T) { @@ -48,40 +46,37 @@ func TestProfitControl_DormantRequestPricingContext(t *testing.T) { }) } -// Failover keeps the request price instant; neither a legacy setting change -// nor a carried private gate can re-enable admission at a runtime boundary. -func TestProfitControl_DormantFailoverKeepsPricingContext(t *testing.T) { +// failover 重入复用同一门:请求中途分组配置变化不得改变本请求阈值。 +func TestProfitControl_GateReuseKeepsThresholdAcrossFailover(t *testing.T) { svc := &OpenAIGatewayService{} groupID := int64(62) group := profitControlTestGroup(groupID, 0.5, 0) - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(group), &groupID) - gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, gate) + ctx := svc.withOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) + gate, ok := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.True(t, ok) + require.InDelta(t, 0.5, gate.threshold, 1e-12) // 模拟请求进行中管理员改配置(ctx 分组为同一指针,与 auth 快照语义一致)。 group.ProfitMinMargin = 0.9 reCtx := svc.withOpenAIProfitControlGate(ctx, &groupID) - reGate, _ := reCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, reGate) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(reCtx)) - require.Equal(t, 0.9, group.ProfitMinMargin, "retirement does not rewrite legacy rows") + reGate, ok := reCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.True(t, ok) + require.Same(t, gate, reGate, "failover 重入必须复用同一门,阈值不得中途变化") // 换分组(composite/模型路由成员调度)重新解析;成员分组无门时必须清除 // 父分组门,阈值不得跨组泄漏。 otherID := int64(63) - carried := context.WithValue(reCtx, openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{groupID: groupID, threshold: 0}) - otherCtx := svc.withOpenAIProfitControlGate(carried, &otherID) + otherCtx := svc.withOpenAIProfitControlGate(reCtx, &otherID) otherGate, _ := otherCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) require.Nil(t, otherGate, "成员分组未启用利润控制时父分组门必须清除") - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(otherCtx)) now := time.Now() expensive := upstreamCostTestAccount(8, UpstreamBillingProbeStatusOK, 0.9, now.Add(-time.Minute), 30*time.Minute) vetoed, _ := openAIProfitControlVetoReason(otherCtx, expensive) require.False(t, vetoed) } -// Billing still uses pricingAt and its peak factor without a runtime gate. -func TestProfitControl_DormantAdmissionPreservesPeakPricingAt(t *testing.T) { +// D 固定在 pricingAt:高峰因子按请求开始时刻计算,与"当前时刻"无关。 +func TestProfitControl_PricingAtFixesDownstreamPeakFactor(t *testing.T) { svc := &OpenAIGatewayService{} groupID := int64(64) group := profitControlTestGroup(groupID, 0, 0) @@ -98,11 +93,9 @@ func TestProfitControl_DormantAdmissionPreservesPeakPricingAt(t *testing.T) { ctx := context.WithValue(profitControlTestCtx(group), openAIPricingAtCtxKey{}, pricingAt) gate := svc.resolveOpenAIProfitControlGate(ctx, &groupID) - require.Nil(t, gate) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) - require.Equal(t, pricingAt, openAIUsagePricingAt(&OpenAIRecordUsageInput{PricingAt: pricingAt})) - require.Equal(t, 3.0, group.PeakMultiplierAt(pricingAt), "billing peak factor must not change with admission retirement") - require.Equal(t, 1.0, group.PeakMultiplierAt(outsideWindow)) + require.NotNil(t, gate) + require.InDelta(t, 3.0, gate.threshold, 1e-9, "阈值必须用 pricingAt 时刻的高峰因子(1.0×3.0×(1-0))") + require.Equal(t, pricingAt, gate.pricingAt) } // U 只取账号倍率:探测快照内容和新鲜度不再直接参与利润判断。 @@ -116,9 +109,9 @@ func TestProfitControl_UsesAccountRateInsteadOfProbeSnapshot(t *testing.T) { require.Equal(t, openAIProfitFilterReasonThreshold, reason) } -// Endpoint capability selection remains active; retired admission cannot turn -// otherwise valid Chat/Responses candidates into no-available errors. -func TestProfitControl_DormantAdmissionKeepsResponsesSelection(t *testing.T) { +// Responses 是端点能力,不代表媒体请求;原生远程压缩同样要求该能力, +// 因此唯一文本调度入口必须照常安装利润门。 +func TestProfitControl_ResponsesCapabilityUsesTextGateAtScheduler(t *testing.T) { now := time.Now() expensive := upstreamCostTestAccount(51, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute) expensive.Status = StatusActive @@ -131,23 +124,19 @@ func TestProfitControl_DormantAdmissionKeepsResponsesSelection(t *testing.T) { concurrencyService: NewConcurrencyService(stubConcurrencyCache{}), } groupID := int64(77) - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)), &groupID) - for _, capability := range []OpenAIEndpointCapability{OpenAIEndpointCapabilityChatCompletions, OpenAIEndpointCapabilityResponses} { - selection, _, err := svc.SelectAccountWithSchedulerForCapability(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, capability, false, false, true) - require.NoError(t, err) - require.NotNil(t, selection) - require.Equal(t, expensive.ID, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) - if selection.ReleaseFunc != nil { - selection.ReleaseFunc() - } - } + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + + _, _, err := svc.SelectAccountWithSchedulerForCapability(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityChatCompletions, false, false, true) + require.ErrorIs(t, err, ErrNoAvailableAccounts, "文本能力必须过利润门") + + selection, _, err := svc.SelectAccountWithSchedulerForCapability(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, OpenAIEndpointCapabilityResponses, false, false, true) + require.ErrorIs(t, err, ErrNoAvailableAccounts, "Responses 文本能力不得绕过利润门") + require.Nil(t, selection) } -// Runtime retirement leaves missing/manual rates untouched; the private-gate -// numerical classifier has separate unchanged compatibility tests. -func TestProfitControl_DormantAdmissionDoesNotClassifyAccountRates(t *testing.T) { +// 账号倍率缺失一律视为非法保守拒绝;手工或同步维护了倍率的任意账号类型都按 +// 同一阈值判断(OAuth 与 API Key 无差别)。 +func TestProfitControl_AccountRateSemantics(t *testing.T) { now := time.Now() missing := upstreamCostTestOAuthAccount(2) manualOAuth := profitControlTestAccountWithRate(upstreamCostTestOAuthAccount(3), 0.3) @@ -157,16 +146,19 @@ func TestProfitControl_DormantAdmissionDoesNotClassifyAccountRates(t *testing.T) group.RateMultiplier = 1 base := context.WithValue(profitControlTestCtx(group), openAIPricingAtCtxKey{}, now) gate := (&OpenAIGatewayService{}).resolveOpenAIProfitControlGate(base, &group.ID) - require.Nil(t, gate) - for _, account := range []*Account{missing, manualOAuth, expensive} { - vetoed, reason := openAIProfitControlVetoReason(base, account) - require.False(t, vetoed) - require.Empty(t, reason) - } - require.Nil(t, missing.RateMultiplier) - require.Equal(t, 0.3, *manualOAuth.RateMultiplier) - require.Equal(t, 0.8, *expensive.RateMultiplier) - require.Equal(t, now, OpenAIPricingAtFromContext(base)) + require.NotNil(t, gate) + gateCtx := context.WithValue(base, openAIProfitControlGateCtxKey{}, gate) + + vetoed, reason := openAIProfitControlVetoReason(gateCtx, missing) + require.True(t, vetoed, "缺失账号倍率必须保守拒绝") + require.Equal(t, openAIProfitFilterReasonInvalidAccountRate, reason) + + vetoed, _ = openAIProfitControlVetoReason(gateCtx, manualOAuth) + require.False(t, vetoed, "手工维护的 OAuth 倍率应正常准入") + + vetoed, reason = openAIProfitControlVetoReason(gateCtx, expensive) + require.True(t, vetoed) + require.Equal(t, openAIProfitFilterReasonThreshold, reason) } // 用量记录定价时刻:优先请求级 PricingAt,未装配回退记录时刻。 @@ -205,43 +197,41 @@ func TestOpenAIProfitControlStickyBindingOccursOnlyAfterTerminalAdmission(t *tes require.Equal(t, cheapID, cache.sessionBindings[cacheKey], "无既有绑定时应在终检通过后建立粘性") } -// Turn boundaries refresh pricingAt, clear carried gates, and never revive -// admission from historical configuration. -func TestProfitControl_DormantTurnPricingContext(t *testing.T) { +// WithOpenAITurnPricingContext:长连接 turn 边界重新冻结 pricingAt 并按当前 +// 配置重装门(区别于请求级同门复用);已装门时以门所属调度分组为准。 +func TestProfitControl_TurnPricingContext(t *testing.T) { svc := &OpenAIGatewayService{} groupID := int64(63) expensive := upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.8, time.Now().Add(-time.Minute), 30*time.Minute) profitControlTestAccountWithRate(expensive, 0.8) - t.Run("refreshes instant without reviving changed legacy config", func(t *testing.T) { + t.Run("refreshes instant and re-resolves gate config", func(t *testing.T) { group := profitControlTestGroup(groupID, 0.5, 0) base := profitControlTestCtx(group) connCtx, connAt := svc.WithOpenAIRequestPricingContext(base, &groupID) vetoed, _ := OpenAIProfitControlVeto(connCtx, expensive) - require.False(t, vetoed) + require.True(t, vetoed) - // A saved legacy margin change must not reactivate a retired feature. + // 连接中途运营者放宽 margin:turn 级重装必须生效(请求级复用不生效)。 group.ProfitMinMargin = 0.1 turnCtx, turnAt := svc.WithOpenAITurnPricingContext(connCtx, &groupID) require.False(t, turnAt.Before(connAt)) require.Equal(t, turnAt, OpenAIPricingAtFromContext(turnCtx)) vetoed, _ = OpenAIProfitControlVeto(turnCtx, expensive) - require.False(t, vetoed) - require.False(t, gatewayProfitControlGateActive(turnCtx)) - require.Equal(t, 0.1, group.ProfitMinMargin) + require.False(t, vetoed, "turn 级重装应采用最新分组配置") }) - t.Run("clears a carried scheduled-group gate", func(t *testing.T) { + t.Run("keeps scheduled group of the existing gate", func(t *testing.T) { scheduledGroupID := int64(64) scheduled := profitControlTestGroup(scheduledGroupID, 0.5, 0) connCtx, _ := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(scheduled), &scheduledGroupID) - connCtx = context.WithValue(connCtx, openAIProfitControlGateCtxKey{}, &openAIProfitControlGate{groupID: scheduledGroupID, threshold: 0}) // 入口分组与调度分组不同(composite 成员分组场景):turn 重装取门的分组。 entryGroupID := int64(65) - turnCtx, turnAt := svc.WithOpenAITurnPricingContext(connCtx, &entryGroupID) - gate, _ := turnCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, gate) - require.Equal(t, turnAt, OpenAIPricingAtFromContext(turnCtx)) + turnCtx, _ := svc.WithOpenAITurnPricingContext(connCtx, &entryGroupID) + gate, ok := turnCtx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) + require.True(t, ok) + require.NotNil(t, gate) + require.Equal(t, scheduledGroupID, gate.groupID) }) t.Run("suppress marker only refreshes instant", func(t *testing.T) { diff --git a/backend/internal/service/openai_profit_control_test.go b/backend/internal/service/openai_profit_control_test.go index 12c7bfd28c65..c0fb51e64d2f 100644 --- a/backend/internal/service/openai_profit_control_test.go +++ b/backend/internal/service/openai_profit_control_test.go @@ -37,7 +37,7 @@ func profitControlTestAccountWithRate(account *Account, rate float64) *Account { return account } -func TestResolveOpenAIProfitControlGateRemainsDormant(t *testing.T) { +func TestResolveOpenAIProfitControlGate(t *testing.T) { svc := &OpenAIGatewayService{} groupID := int64(7) @@ -61,14 +61,14 @@ func TestResolveOpenAIProfitControlGateRemainsDormant(t *testing.T) { require.Nil(t, svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID)) }) - t.Run("grok legacy fields cannot enable a gate", func(t *testing.T) { + t.Run("grok group routed through openai handler installs gate", func(t *testing.T) { group := profitControlTestGroup(groupID, 0.3, 0.05) group.Platform = PlatformGrok group.RateMultiplier = 0.5 gate := svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) - require.Nil(t, gate) - require.True(t, group.ProfitControlEnabled) - require.Equal(t, 0.5, group.RateMultiplier) + require.NotNil(t, gate) + require.Equal(t, PlatformGrok, gate.platform) + require.InDelta(t, 0.5*(1-0.35), gate.threshold, 1e-12) }) t.Run("ctx group id mismatch without snapshot yields no gate", func(t *testing.T) { @@ -76,33 +76,29 @@ func TestResolveOpenAIProfitControlGateRemainsDormant(t *testing.T) { require.Nil(t, svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID)) }) - t.Run("legacy margin and buffer do not change ordinary billing rate", func(t *testing.T) { + t.Run("threshold composes margin and buffer from downstream rate", func(t *testing.T) { group := profitControlTestGroup(groupID, 0.3, 0.05) group.RateMultiplier = 2.0 gate := svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) - require.Nil(t, gate) - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(group), &groupID) - require.False(t, pricingAt.IsZero()) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) - require.Equal(t, 2.0, group.RateMultiplier) - require.Equal(t, 0.3, group.ProfitMinMargin) - require.Equal(t, 0.05, group.ProfitSafetyBuffer) + require.NotNil(t, gate) + require.InDelta(t, 2.0*(1-0.35), gate.threshold, 1e-12) + require.Equal(t, PlatformOpenAI, gate.platform) + require.False(t, gate.pricingAt.IsZero()) + require.Equal(t, groupID, gate.groupID) }) - t.Run("retirement preserves peak factor without installing admission", func(t *testing.T) { + t.Run("threshold applies peak factor exactly like billing", func(t *testing.T) { group := profitControlTestGroup(groupID, 0.5, 0) group.SubscriptionType = SubscriptionTypeSubscription group.PeakRateEnabled = true group.PeakStart = "00:00" group.PeakEnd = "23:59" group.PeakRateMultiplier = 3.0 - fixed := time.Date(2026, 1, 15, 12, 0, 0, 0, timezone.Location()) - expected := group.PeakMultiplierAt(fixed) - require.Equal(t, 3.0, expected) gate := svc.resolveOpenAIProfitControlGate(profitControlTestCtx(group), &groupID) - require.Nil(t, gate) - require.Equal(t, expected, group.PeakMultiplierAt(fixed)) - require.Equal(t, 1.0, group.RateMultiplier) + require.NotNil(t, gate) + expected := group.RateMultiplier * group.PeakMultiplierAt(timezone.Now()) * 0.5 + require.InDelta(t, expected, gate.threshold, 1e-9) + require.Equal(t, PlatformOpenAI, gate.platform) }) } @@ -184,7 +180,7 @@ func TestOpenAIProfitControlVetoReason(t *testing.T) { }) } -func TestProfitControlSchedulerDormantKeepsEligibleCandidates(t *testing.T) { +func TestProfitControlSchedulerFiltersCandidates(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() defer resetOpenAIAdvancedSchedulerSettingCacheForTest() @@ -213,40 +209,37 @@ func TestProfitControlSchedulerDormantKeepsEligibleCandidates(t *testing.T) { } groupID := int64(7) - t.Run("legacy profit fields cannot exclude an otherwise eligible account", func(t *testing.T) { - ctx, pricingAt := svc.WithOpenAIRequestPricingContext(profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)), &groupID) - for _, expected := range []int64{cheap.ID, expensive.ID, oauth.ID} { - excluded := map[int64]struct{}{cheap.ID: {}, expensive.ID: {}, oauth.ID: {}} - delete(excluded, expected) - selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", excluded, OpenAIUpstreamTransportAny, false) + t.Run("unprofitable and invalid-rate accounts never win", func(t *testing.T) { + // margin 0.5 → 阈值 0.5:expensive(0.8) 超阈值、oauth 倍率缺失,仅 cheap 可选。 + ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.5, 0)) + for i := 0; i < 5; i++ { + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.NoError(t, err) require.NotNil(t, selection) - require.Equal(t, expected, selection.Account.ID) - require.False(t, selection.ProfitGateActive()) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) + require.Equal(t, cheap.ID, selection.Account.ID) if selection.ReleaseFunc != nil { selection.ReleaseFunc() } } }) - t.Run("explicit exclusions still exhaust the pool without profit reasons", func(t *testing.T) { + t.Run("all excluded surfaces standard no-available error with profit reasons", func(t *testing.T) { + // margin+buffer 0.8 → 阈值 0.2:cheap/expensive 超阈值,oauth 倍率非法。 ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.7, 0.1)) - excluded := map[int64]struct{}{cheap.ID: {}, expensive.ID: {}, oauth.ID: {}} - selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", excluded, OpenAIUpstreamTransportAny, false) + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.Nil(t, selection) require.Error(t, err) require.True(t, errors.Is(err, ErrNoAvailableAccounts)) - require.NotContains(t, err.Error(), openAIProfitFilterReasonThreshold) - require.NotContains(t, err.Error(), openAIProfitFilterReasonInvalidAccountRate) + require.Contains(t, err.Error(), openAIProfitFilterReasonThreshold+"=2") + require.Contains(t, err.Error(), openAIProfitFilterReasonInvalidAccountRate+"=1") }) t.Run("manually rated oauth account is admitted", func(t *testing.T) { - // Explicit request exclusions, not retired profitability, select OAuth. + // 阈值 0.2 排除两个 API Key;OAuth 手工倍率 0.1 可参与调度。 profitControlTestAccountWithRate(oauth, 0.1) svc.accountRepo = schedulerTestOpenAIAccountRepo{accounts: []Account{*cheap, *expensive, *oauth}} ctx := profitControlTestCtx(profitControlTestGroup(groupID, 0.7, 0.1)) - selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", map[int64]struct{}{cheap.ID: {}, expensive.ID: {}}, OpenAIUpstreamTransportAny, false) + selection, _, err := svc.SelectAccountWithScheduler(ctx, &groupID, "", "", "gpt-test", nil, OpenAIUpstreamTransportAny, false) require.NoError(t, err) require.NotNil(t, selection) require.Equal(t, oauth.ID, selection.Account.ID) diff --git a/backend/internal/service/openai_ws_forwarder_support.go b/backend/internal/service/openai_ws_forwarder_support.go index 2f5a453582ed..e2a176661e4f 100644 --- a/backend/internal/service/openai_ws_forwarder_support.go +++ b/backend/internal/service/openai_ws_forwarder_support.go @@ -534,6 +534,8 @@ func (s *OpenAIGatewayService) SelectAccountByPreviousResponseID( excludedIDs map[int64]struct{}, requireCompact bool, ) (*AccountSelectionResult, error) { + // 分组利润控制:公共入口装门,保证不经 selectAccountWithScheduler + // 的调用方也无法绕过利润准入(scheduler 内部路径已在唯一调度入口装门)。 ctx = s.withOpenAIProfitControlGate(ctx, groupID) return s.selectAccountByPreviousResponseIDForCapability(ctx, groupID, previousResponseID, requestedModel, excludedIDs, "", requireCompact) } @@ -809,6 +811,9 @@ func (s *OpenAIGatewayService) resolveAccountByPreviousResponseIDForCapability( if paused, _ := shouldAutoPauseOpenAIAccountByQuota(ctx, account); paused { return policyMiss() } + // 分组利润控制:与 quota auto-pause 同语义——利润不合格是暂时 + // 状态(上游倍率/高峰随时间变化),只跳过本次复用、落回普通调度,不删除 + // 绑定(倍率恢复后可继续按 previous_response_id 粘连)。 if vetoed, _ := openAIProfitControlVetoReason(ctx, account); vetoed { return policyMiss() } diff --git a/backend/internal/service/profit_control_dormant_regression_test.go b/backend/internal/service/profit_control_dormant_regression_test.go deleted file mode 100644 index 25e73dfcd481..000000000000 --- a/backend/internal/service/profit_control_dormant_regression_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package service - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" - "github.com/stretchr/testify/require" -) - -type dormantProfitGroupRepository struct { - GroupRepository - group *Group - reads []int64 -} - -func (r *dormantProfitGroupRepository) GetByIDLite(_ context.Context, id int64) (*Group, error) { - r.reads = append(r.reads, id) - if r.group == nil || r.group.ID != id { - return nil, fmt.Errorf("unexpected synthetic group lookup %d", id) - } - copy := *r.group - return ©, nil -} - -func TestDormantProfitControlCrossGroupSnapshotCannotEnableAdmission(t *testing.T) { - for _, engine := range []string{"openai", "gateway"} { - t.Run(engine, func(t *testing.T) { - // Auth-cache zeroing cannot sanitize a different scheduled group. - // Keep a legacy-enabled row as the real snapshot service's repository - // result, matching the current Ent projection's retained fields. - legacy := &Group{ID: 902, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, - RateMultiplier: 0.2, ProfitControlEnabled: true, ProfitMinMargin: 0.3, ProfitSafetyBuffer: 0.05} - if engine == "gateway" { - legacy.Platform = PlatformAnthropic - } - preserved := *legacy - repository := &dormantProfitGroupRepository{group: legacy} - snapshot := NewSchedulerSnapshotService(nil, nil, nil, repository, nil) - projected, err := snapshot.GetGroupByIDLite(context.Background(), legacy.ID) - require.NoError(t, err) - require.True(t, projected.ProfitControlEnabled, "fixture must not silently zero the legacy projection") - require.Equal(t, []int64{legacy.ID}, repository.reads) - billing := &Group{ID: 901, Platform: PlatformComposite, Status: StatusActive, Hydrated: true, - RateMultiplier: 0.7, SubscriptionType: SubscriptionTypeStandard} - base := context.WithValue(context.Background(), ctxkey.Group, billing) - var ctx context.Context - var pricingAt time.Time - if engine == "openai" { - ctx, pricingAt = (&OpenAIGatewayService{schedulerSnapshot: snapshot}).WithOpenAIRequestPricingContext(base, &legacy.ID) - require.Equal(t, pricingAt, OpenAIPricingAtFromContext(ctx)) - } else { - ctx, pricingAt = WithGatewayTokenRequestPricing(base) - ctx = (&GatewayService{schedulerSnapshot: snapshot}).withGatewayProfitControlGate(ctx, &legacy.ID) - actual, ok := gatewayTokenRequestPricingAtFromContext(ctx) - require.True(t, ok) - require.Equal(t, pricingAt, actual) - } - require.False(t, pricingAt.IsZero(), "retirement cannot remove the billing pricing instant") - gate, _ := ctx.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, gate, "legacy cross-group fields cannot reactivate the retired gate; lookups=%v", repository.reads) - require.Equal(t, []int64{legacy.ID}, repository.reads, "runtime retirement must not depend on reading or rewriting a legacy row") - rate := 5.0 - vetoed, reason := OpenAIProfitControlVeto(ctx, &Account{ID: 903, RateMultiplier: &rate}) - require.False(t, vetoed) - require.Empty(t, reason) - require.Equal(t, preserved, *legacy, "stored configuration remains untouched") - require.Equal(t, 0.7, billing.RateMultiplier, "ordinary billing multipliers remain untouched") - }) - } -} - -func TestDormantProfitControlClearsGateAtBoundariesButKeepsPrivateMath(t *testing.T) { - now := time.Now().Add(-time.Minute) - gate := &openAIProfitControlGate{groupID: 12, platform: PlatformOpenAI, threshold: 0, pricingAt: now} - base := context.WithValue(context.Background(), openAIPricingAtCtxKey{}, now) - base = context.WithValue(base, openAIProfitControlGateCtxKey{}, gate) - rate := 5.0 - account := &Account{ID: 13, RateMultiplier: &rate} - - vetoed, reason := openAIProfitControlVetoReason(base, account) - require.True(t, vetoed, "private numerical predicates remain available to upstream compatibility fixtures") - require.Equal(t, openAIProfitFilterReasonThreshold, reason) - latest, vetoed, reason := profitControlVetoLatest(base, account, nil) - require.Same(t, account, latest) - require.True(t, vetoed) - require.Equal(t, openAIProfitFilterReasonThreshold, reason) - - request, requestAt := (&OpenAIGatewayService{}).WithOpenAIRequestPricingContext(base, &gate.groupID) - carried, _ := request.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, carried) - require.Equal(t, requestAt, OpenAIPricingAtFromContext(request)) - require.False(t, gatewayProfitControlGateActive(request)) - vetoed, reason = openAIProfitControlVetoReason(request, account) - require.False(t, vetoed) - require.Empty(t, reason) - selection := &AccountSelectionResult{Account: account} - require.Same(t, selection, attachSelectionProfitGate(request, selection)) - require.Nil(t, selection.profitGate) - require.Same(t, request, ContextWithSelectionProfitGate(request, selection)) - turn, turnAt := (&OpenAIGatewayService{}).WithOpenAITurnPricingContext(base, &gate.groupID) - carried, _ = turn.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, carried) - require.Equal(t, turnAt, OpenAIPricingAtFromContext(turn)) - require.False(t, turnAt.Before(now)) - general := (&GatewayService{}).withGatewayProfitControlGate(base, &gate.groupID) - carried, _ = general.Value(openAIProfitControlGateCtxKey{}).(*openAIProfitControlGate) - require.Nil(t, carried) -} diff --git a/backend/internal/service/profit_preview.go b/backend/internal/service/profit_preview.go new file mode 100644 index 000000000000..d63b9317cecf --- /dev/null +++ b/backend/internal/service/profit_preview.go @@ -0,0 +1,211 @@ +package service + +// 分组利润控制离线预演:使用生产只读导出的分组配置、账号倍率、探测状态、 +// 用户覆盖倍率与主力模型清单,复用线上 U/D/epsilon 语义推演准入。 +// 探测状态仅用于解释账号倍率来源是否健康,不影响准入结论。 + +import ( + "math" + "sort" + "time" +) + +const ( + ProfitPreviewRateSourceManual = "manual" + ProfitPreviewRateSourceUpstreamProbe = "upstream_probe_sync" + + ProfitPreviewWarningProbeMissing = "probe_snapshot_missing" + ProfitPreviewWarningProbeStale = "probe_snapshot_stale" + ProfitPreviewWarningProbeFailed = "probe_failed" + ProfitPreviewWarningProbeUnsupported = "probe_unsupported" + ProfitPreviewWarningManualRateOne = "manual_rate_1_suspected_unmaintained" +) + +// ProfitPreviewGroupInput 是一个分组的预演输入。 +type ProfitPreviewGroupInput struct { + Group *Group + // Accounts 为该分组绑定且状态可调度的账号,允许 Anthropic/Gemini 分组中 + // 参与 mixed scheduling 的 Antigravity 账号。 + Accounts []*Account + // UserOverrides 为该分组所有用户覆盖倍率(user_id → 倍率)。 + UserOverrides map[int64]float64 + // Models 为该分组近期主力模型清单。 + Models []string + // AssumeEnabled 在分组当前关闭时仍按其保存配置启用利润门,供部署前执行 + // “先关组、预演、逐组重开”。 + AssumeEnabled bool +} + +// 预演账号分类与线上否决原因一一对应。 +const ( + ProfitPreviewClassAdmitted = "admitted" + ProfitPreviewClassRejectedThreshold = "rejected_threshold" + ProfitPreviewClassRejectedInvalidRate = "rejected_invalid_account_rate" +) + +// ProfitPreviewAccountVerdict 是单账号的预演结论。 +type ProfitPreviewAccountVerdict struct { + AccountID int64 `json:"account_id"` + Name string `json:"name"` + Platform string `json:"platform"` + Class string `json:"class"` + AccountRate *float64 `json:"account_rate,omitempty"` + RateSource string `json:"rate_source"` + Warnings []string `json:"warnings,omitempty"` + // RejectedUnderMinD 表示账号在最低有效 D(用户覆盖最坏口径)下会被排除。 + RejectedUnderMinD bool `json:"rejected_under_min_d,omitempty"` + // SupportedModels 为该账号支持的主力模型子集。 + SupportedModels []string `json:"supported_models,omitempty"` +} + +// ProfitPreviewGroupReport 是单分组的预演报告。 +type ProfitPreviewGroupReport struct { + GroupID int64 `json:"group_id"` + GroupName string `json:"group_name"` + Platform string `json:"platform"` + EffectiveGate bool `json:"effective_gate"` + AssumedEnabled bool `json:"assumed_enabled,omitempty"` + // DefaultD / MinEffectiveD:分组默认 D 与最低有效 D(含 evalAt 高峰因子; + // MinEffectiveD = min(分组默认, 全部非空用户覆盖) × 高峰因子)。 + DefaultD float64 `json:"default_d"` + MinEffectiveD float64 `json:"min_effective_d"` + // ThresholdDefault / ThresholdMinD:两档 D 对应的准入阈值。 + ThresholdDefault float64 `json:"threshold_default"` + ThresholdMinD float64 `json:"threshold_min_d"` + // RemainingByModel 仅表示利润门准入账号数,不模拟健康、冷却、限流或槽位。 + RemainingByModel map[string]int `json:"profit_admitted_by_model"` + RemainingByModelMinD map[string]int `json:"profit_admitted_by_model_min_d"` + Verdicts []ProfitPreviewAccountVerdict `json:"verdicts"` +} + +// PreviewProfitAdmission 对五大平台分组推演利润门准入结果。未启用的分组仅在 +// AssumeEnabled=true 时按保存配置执行门;Composite 分组始终不直接安装利润门。 +func PreviewProfitAdmission(inputs []ProfitPreviewGroupInput, evalAt time.Time) []ProfitPreviewGroupReport { + reports := make([]ProfitPreviewGroupReport, 0, len(inputs)) + for _, in := range inputs { + if in.Group == nil { + continue + } + group := in.Group + effectiveGate := (group.ProfitControlEnabled || in.AssumeEnabled) && profitControlPlatformSupported(group.Platform) + report := ProfitPreviewGroupReport{ + GroupID: group.ID, + GroupName: group.Name, + Platform: group.Platform, + EffectiveGate: effectiveGate, + AssumedEnabled: in.AssumeEnabled && !group.ProfitControlEnabled && effectiveGate, + RemainingByModel: make(map[string]int, len(in.Models)), + RemainingByModelMinD: make(map[string]int, len(in.Models)), + } + for _, model := range in.Models { + report.RemainingByModel[model] = 0 + report.RemainingByModelMinD[model] = 0 + } + + peak := group.PeakMultiplierAt(evalAt) + defaultD := group.RateMultiplier * peak + minRate := group.RateMultiplier + for _, override := range in.UserOverrides { + if math.IsNaN(override) || math.IsInf(override, 0) || override < 0 { + continue + } + if override < minRate { + minRate = override + } + } + minD := minRate * peak + deduction := group.ProfitMinMargin + group.ProfitSafetyBuffer + thresholdDefault := clampProfitControlThreshold(defaultD * (1 - deduction)) + thresholdMinD := clampProfitControlThreshold(minD * (1 - deduction)) + report.DefaultD = defaultD + report.MinEffectiveD = minD + report.ThresholdDefault = thresholdDefault + report.ThresholdMinD = thresholdMinD + + for _, account := range in.Accounts { + if account == nil { + continue + } + verdict := previewAccountProfitAdmission(account, effectiveGate, thresholdDefault, thresholdMinD, evalAt) + admittedDefault := verdict.Class == ProfitPreviewClassAdmitted + admittedMinD := admittedDefault && !verdict.RejectedUnderMinD + for _, model := range in.Models { + if !account.IsModelSupported(model) { + continue + } + verdict.SupportedModels = append(verdict.SupportedModels, model) + if admittedDefault { + report.RemainingByModel[model]++ + } + if admittedMinD { + report.RemainingByModelMinD[model]++ + } + } + sort.Strings(verdict.SupportedModels) + report.Verdicts = append(report.Verdicts, verdict) + } + sort.Slice(report.Verdicts, func(i, j int) bool { return report.Verdicts[i].AccountID < report.Verdicts[j].AccountID }) + reports = append(reports, report) + } + return reports +} + +func previewAccountProfitAdmission( + account *Account, + effectiveGate bool, + thresholdDefault float64, + thresholdMinD float64, + evalAt time.Time, +) ProfitPreviewAccountVerdict { + verdict := ProfitPreviewAccountVerdict{ + AccountID: account.ID, + Name: account.Name, + Platform: account.Platform, + RateSource: ProfitPreviewRateSourceManual, + } + if enabled, _ := account.Extra[UpstreamBillingRateSyncEnabledExtraKey].(bool); enabled { + verdict.RateSource = ProfitPreviewRateSourceUpstreamProbe + verdict.Warnings = append(verdict.Warnings, profitPreviewProbeWarnings(account, evalAt)...) + } else if account.RateMultiplier != nil && *account.RateMultiplier == 1 { + verdict.Warnings = append(verdict.Warnings, ProfitPreviewWarningManualRateOne) + } + + validRate := account.RateMultiplier != nil && + !math.IsNaN(*account.RateMultiplier) && + !math.IsInf(*account.RateMultiplier, 0) && + *account.RateMultiplier >= 0 + if validRate { + rate := *account.RateMultiplier + verdict.AccountRate = &rate + } + switch { + case !effectiveGate: + verdict.Class = ProfitPreviewClassAdmitted + case !validRate: + verdict.Class = ProfitPreviewClassRejectedInvalidRate + case profitControlOverThreshold(*account.RateMultiplier, thresholdDefault): + verdict.Class = ProfitPreviewClassRejectedThreshold + default: + verdict.Class = ProfitPreviewClassAdmitted + verdict.RejectedUnderMinD = profitControlOverThreshold(*account.RateMultiplier, thresholdMinD) + } + return verdict +} + +func profitPreviewProbeWarnings(account *Account, evalAt time.Time) []string { + snapshot := decodeUpstreamBillingProbeSnapshot(account.Extra) + if snapshot == nil { + return []string{ProfitPreviewWarningProbeMissing} + } + switch snapshot.Status { + case UpstreamBillingProbeStatusFailed: + return []string{ProfitPreviewWarningProbeFailed} + case UpstreamBillingProbeStatusUnsupported: + return []string{ProfitPreviewWarningProbeUnsupported} + case UpstreamBillingProbeStatusOK: + if snapshot.FreshUntil == nil || !evalAt.Before(*snapshot.FreshUntil) { + return []string{ProfitPreviewWarningProbeStale} + } + } + return nil +} diff --git a/backend/internal/service/profit_preview_test.go b/backend/internal/service/profit_preview_test.go new file mode 100644 index 000000000000..1030ef41aa09 --- /dev/null +++ b/backend/internal/service/profit_preview_test.go @@ -0,0 +1,146 @@ +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestPreviewProfitAdmissionUsesAccountRatesAndPreinitializesModels(t *testing.T) { + now := time.Now() + group := profitControlTestGroup(50, 0.2, 0) + group.Name = "VIP-preview" + + cheap := profitControlTestAccountWithRate( + upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 1.0, now.Add(-3*time.Hour), 30*time.Minute), + 0.5, + ) + cheap.Name = "cheap" + cheap.Extra[UpstreamBillingRateSyncEnabledExtraKey] = true + cheap.Credentials = map[string]any{"model_mapping": map[string]any{"gpt-sol": "gpt-sol"}} + + boundary := profitControlTestAccountWithRate( + upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.8, now.Add(-time.Minute), 30*time.Minute), + 0.8, + ) + boundary.Name = "boundary" + boundary.Credentials = map[string]any{"model_mapping": map[string]any{ + "gpt-sol": "gpt-sol", + "gpt-luna": "gpt-luna", + }} + + expensive := profitControlTestAccountWithRate( + upstreamCostTestAccount(3, UpstreamBillingProbeStatusOK, 0.2, now.Add(-time.Minute), 30*time.Minute), + 1.0, + ) + expensive.Name = "expensive" + + invalid := upstreamCostTestOAuthAccount(4) + invalid.Name = "invalid" + + reports := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{cheap, boundary, expensive, invalid}, + UserOverrides: map[int64]float64{40: 0.5}, + Models: []string{"gpt-sol", "gpt-luna", "gpt-no-account"}, + }}, now) + require.Len(t, reports, 1) + report := reports[0] + + require.True(t, report.EffectiveGate) + require.InDelta(t, 1.0, report.DefaultD, 1e-12) + require.InDelta(t, 0.8, report.ThresholdDefault, 1e-12) + require.InDelta(t, 0.5, report.MinEffectiveD, 1e-12) + require.InDelta(t, 0.4, report.ThresholdMinD, 1e-12) + + byID := map[int64]ProfitPreviewAccountVerdict{} + for _, verdict := range report.Verdicts { + byID[verdict.AccountID] = verdict + } + require.Equal(t, ProfitPreviewClassAdmitted, byID[cheap.ID].Class) + require.Equal(t, ProfitPreviewRateSourceUpstreamProbe, byID[cheap.ID].RateSource) + require.Contains(t, byID[cheap.ID].Warnings, ProfitPreviewWarningProbeStale) + require.True(t, byID[cheap.ID].RejectedUnderMinD) + + require.Equal(t, ProfitPreviewClassAdmitted, byID[boundary.ID].Class, "U == 阈值按 epsilon 语义准入") + require.Equal(t, ProfitPreviewClassRejectedThreshold, byID[expensive.ID].Class) + require.Contains(t, byID[expensive.ID].Warnings, ProfitPreviewWarningManualRateOne) + require.Equal(t, ProfitPreviewClassRejectedInvalidRate, byID[invalid.ID].Class) + + require.Equal(t, 2, report.RemainingByModel["gpt-sol"]) + require.Equal(t, 1, report.RemainingByModel["gpt-luna"]) + require.Equal(t, 0, report.RemainingByModel["gpt-no-account"]) + _, present := report.RemainingByModel["gpt-no-account"] + require.True(t, present, "全部账号均不支持时也必须显式保留 0,供 CLI 发出警告") +} + +func TestPreviewProfitAdmissionAssumeEnabled(t *testing.T) { + now := time.Now() + group := profitControlTestGroup(51, 0, 0) + group.Platform = PlatformOpenAI + group.RateMultiplier = 0.5 + group.ProfitControlEnabled = false + + cheapAccount := profitControlTestAccountWithRate( + upstreamCostTestAccount(1, UpstreamBillingProbeStatusOK, 0.9, now.Add(-time.Minute), 30*time.Minute), + 0.2, + ) + expensiveAccount := profitControlTestAccountWithRate( + upstreamCostTestAccount(2, UpstreamBillingProbeStatusOK, 0.2, now.Add(-time.Minute), 30*time.Minute), + 0.9, + ) + + withoutAssume := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{cheapAccount, expensiveAccount}, + Models: []string{"gpt-test"}, + }}, now)[0] + require.False(t, withoutAssume.EffectiveGate) + require.Equal(t, ProfitPreviewClassAdmitted, withoutAssume.Verdicts[0].Class) + require.Equal(t, ProfitPreviewClassAdmitted, withoutAssume.Verdicts[1].Class) + + withAssume := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{cheapAccount, expensiveAccount}, + Models: []string{"gpt-test"}, + AssumeEnabled: true, + }}, now)[0] + require.True(t, withAssume.EffectiveGate) + require.True(t, withAssume.AssumedEnabled) + + byID := map[int64]ProfitPreviewAccountVerdict{} + for _, verdict := range withAssume.Verdicts { + byID[verdict.AccountID] = verdict + } + require.Equal(t, ProfitPreviewClassAdmitted, byID[cheapAccount.ID].Class, + "账号倍率 0.2 <= 阈值 0.5,探测快照的高倍率不参与准入判断") + require.Equal(t, ProfitPreviewClassRejectedThreshold, byID[expensiveAccount.ID].Class, + "账号倍率 0.9 > 阈值 0.5,探测快照的低倍率不能替代账号倍率") +} + +func TestPreviewProfitAdmissionSupportsFivePlatforms(t *testing.T) { + for i, platform := range []string{ + PlatformOpenAI, + PlatformAnthropic, + PlatformGemini, + PlatformGrok, + PlatformAntigravity, + } { + group := profitControlTestGroup(int64(100+i), 0, 0) + group.Platform = platform + rate := 0.2 + account := &Account{ + ID: int64(200 + i), + Platform: platform, + RateMultiplier: &rate, + } + report := PreviewProfitAdmission([]ProfitPreviewGroupInput{{ + Group: group, + Accounts: []*Account{account}, + Models: []string{"model"}, + }}, time.Now())[0] + require.True(t, report.EffectiveGate, platform) + require.Equal(t, ProfitPreviewClassAdmitted, report.Verdicts[0].Class, platform) + } +} diff --git a/backend/internal/service/scheduler_snapshot_service.go b/backend/internal/service/scheduler_snapshot_service.go index 86e0fe6bc8c4..fa1b566de796 100644 --- a/backend/internal/service/scheduler_snapshot_service.go +++ b/backend/internal/service/scheduler_snapshot_service.go @@ -306,7 +306,10 @@ func (s *SchedulerSnapshotService) GetGroupByID(ctx context.Context, groupID int return s.groupRepo.GetByID(ctx, groupID) } -// GetGroupByIDLite returns scheduling fields without account-count aggregation. +// GetGroupByIDLite 获取分组配置但不加载账号计数聚合。 +// 利润门只需要平台、倍率、利润与高峰字段,GetByID 附带的那条账号计数聚合 +// 查询纯属浪费——composite / 模型路由 / fallback 每次装门都要付一次,WS 更是 +// 每个 turn 一次,且发生在「是否启用利润控制」判定之前。 func (s *SchedulerSnapshotService) GetGroupByIDLite(ctx context.Context, groupID int64) (*Group, error) { if s.groupRepo == nil { return nil, nil From 9494e8a405a5d289abdbf40c3be6ad01af4ec7a5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 18:13:08 +0800 Subject: [PATCH 02/39] fix(groups): restore upstream's profit control section in the group dialogs The create and edit dialogs get upstream's editable section back: the switch, the minimum gross margin and the safety buffer in percent, and the validation before submit, with groupsProfitControl.ts, its spec, the profitControl texts of both locales and the request types. The read-only block of the edit dialog, its retiredProfitControl texts and the comment that called the fields deprecated are removed. The console theme needs no rule and no hook: the section has the markup of the peak-rate block above it, and its divider, checkbox, label, hint, field labels and inputs resolve to the same computed styles as their neighbours in light and dark. Co-Authored-By: Claude Opus 5.5 --- .../src/i18n/locales/en/admin/overview.ts | 21 +- .../src/i18n/locales/zh/admin/overview.ts | 21 +- frontend/src/types/index.ts | 12 +- frontend/src/views/admin/GroupsView.vue | 222 ++++++++++++++---- .../__tests__/groupsProfitControl.spec.ts | 181 ++++++++++++++ .../src/views/admin/groupsProfitControl.ts | 69 ++++++ 6 files changed, 461 insertions(+), 65 deletions(-) create mode 100644 frontend/src/views/admin/__tests__/groupsProfitControl.spec.ts create mode 100644 frontend/src/views/admin/groupsProfitControl.ts diff --git a/frontend/src/i18n/locales/en/admin/overview.ts b/frontend/src/i18n/locales/en/admin/overview.ts index 8aec554ef13f..ec4028653cb4 100644 --- a/frontend/src/i18n/locales/en/admin/overview.ts +++ b/frontend/src/i18n/locales/en/admin/overview.ts @@ -1133,6 +1133,18 @@ export default { peakMultiplier: 'Peak multiplier', multiplierHint: 'Applies to token billing multiplier; image tokens in token billing are also affected. 0 means peak token requests are billed at 0x.' }, + profitControl: { + enable: 'Enable profit control', + enabledHint: 'Scheduling only admits accounts whose account multiplier ≤ the request\'s effective downstream multiplier × (1 − min margin − safety buffer). Account multipliers may be maintained manually or synchronized from probes; existing ordering, stickiness and breakers keep working among qualified accounts. Image/video scheduling is not covered yet.', + disabledHint: 'When disabled, scheduling does no profit filtering: accounts whose account multiplier exceeds the downstream multiplier can still be selected, which may produce loss-making requests.', + minMargin: 'Min gross margin (%)', + minMarginHint: 'Percent input, e.g. 30 means 30%; stored as a decimal on the backend', + safetyBuffer: 'Safety buffer (%)', + safetyBufferHint: 'Added to min margin and deducted from the downstream multiplier; defaults to 0', + marginRangeError: 'Min gross margin must be between 0 and 99.99', + bufferRangeError: 'Safety buffer must be between 0 and 99.99', + sumTooHigh: 'Min gross margin plus safety buffer must be less than 100%, otherwise every account would be excluded' + }, modelAllowlist: { title: 'Model Allowlist', hint: 'When enabled, models outside the allowlist are rejected with 404 model_not_found, and model listing endpoints only show allowlisted models. Entries support exact model IDs and * wildcards anywhere (e.g., gpt-*-codex). Note: Claude Code probes with haiku-family models for titles/summaries and /messages/count_tokens is also allowlist-controlled, so make sure the small models you need are selected too.', @@ -1259,15 +1271,6 @@ export default { free: 'Free Fast', freeHint: 'Fast requests in this group still use the priority tier, but customers are charged the equivalent Standard price.' }, - retiredProfitControl: { - title: 'Profit control (retired, read-only, no effect)', - hint: 'Profit control has been removed. These are the stored legacy values; they cannot be changed and do not affect billing or scheduling.', - enabled: 'Enabled', - minMargin: 'Minimum margin', - safetyBuffer: 'Safety buffer', - on: 'On', - off: 'Off' - }, invalidRequestFallback: { title: 'Invalid Request Fallback Group', hint: 'Triggered only when upstream explicitly returns prompt too long. Leave empty to disable fallback.', diff --git a/frontend/src/i18n/locales/zh/admin/overview.ts b/frontend/src/i18n/locales/zh/admin/overview.ts index 09fd67cc6356..deec2a439f75 100644 --- a/frontend/src/i18n/locales/zh/admin/overview.ts +++ b/frontend/src/i18n/locales/zh/admin/overview.ts @@ -1130,6 +1130,18 @@ export default { peakMultiplier: '高峰倍率', multiplierHint: '作用于 token 计费倍率;token 计费的图片 token 同样适用,0 表示高峰 token 请求按 0 倍计费' }, + profitControl: { + enable: '启用利润控制', + enabledHint: '调度时仅允许"账号倍率 ≤ 请求实际下游倍率 ×(1 − 最低毛利率 − 安全缓冲)"的账号进入候选池;账号倍率可手工维护或由探测同步,既有排序、粘性与熔断在合格账号间照常工作。图片/视频调度暂不参与。', + disabledHint: '关闭后调度不做利润过滤,账号倍率高于下游倍率的账号也会被选中,可能产生亏损请求。', + minMargin: '最低毛利率(%)', + minMarginHint: '百分比输入,如 30 表示 30%;后端按小数存储', + safetyBuffer: '安全缓冲(%)', + safetyBufferHint: '与最低毛利率相加后从下游倍率中扣除,默认 0', + marginRangeError: '最低毛利率应在 0 到 99.99 之间', + bufferRangeError: '安全缓冲应在 0 到 99.99 之间', + sumTooHigh: '最低毛利率与安全缓冲之和必须小于 100%,否则将排除全部账号' + }, modelAllowlist: { title: '模型白名单', hint: '开启后,不在白名单中的模型会被拒绝(404 model_not_found),模型列表接口也只展示白名单内的模型。条目支持精确模型 ID 与任意位置的 * 通配(如 gpt-*-codex)。注意:Claude Code 会用 haiku 系小模型做标题/摘要等探测,/messages/count_tokens 同样受白名单控制,请一并勾选所需的小模型。', @@ -1257,15 +1269,6 @@ export default { free: '免费 Fast', freeHint: '该分组的 Fast 请求仍使用 priority 档位,但客户实际费用按同一请求的 Standard 价格计算。' }, - retiredProfitControl: { - title: '利润控制(已退役,只读,不生效)', - hint: '利润控制功能已经下线,这些是库里保存的旧值,不能修改,也不会影响计费或调度。', - enabled: '启用状态', - minMargin: '最低利润率', - safetyBuffer: '安全余量', - on: '开启', - off: '关闭' - }, invalidRequestFallback: { title: '无效请求兜底分组', hint: '仅当上游明确返回 prompt too long 时才会触发,留空表示不兜底', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 65d230d9cc02..dc72cfbb556d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -628,9 +628,9 @@ export interface Group { export interface AdminGroup extends Group { force_openai_fast: boolean free_openai_fast: boolean - // Deprecated read-only compatibility fields: model_pricing is always empty; the profit_* values are the - // stored legacy values (retired: writes are rejected and nothing reads them at runtime). model_pricing: import('@/api/admin/channels').ChannelModelPricing[] + // 分组利润控制(openai/anthropic/gemini/grok/antigravity 分组可启用;margin/buffer 为小数存储)。 + // 仅管理员可见:与 rate_multiplier 相乘即可反推上游成本上限,不得下放到 Group。 profit_control_enabled: boolean profit_min_margin: number profit_safety_buffer: number @@ -827,6 +827,10 @@ export interface CreateGroupRequest { peak_start?: string peak_end?: string peak_rate_multiplier?: number + // 分组利润控制(五个 token 平台;margin/buffer 为小数) + profit_control_enabled?: boolean + profit_min_margin?: number + profit_safety_buffer?: number claude_code_only?: boolean fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null @@ -889,6 +893,10 @@ export interface UpdateGroupRequest { peak_start?: string peak_end?: string peak_rate_multiplier?: number + // 分组利润控制(五个 token 平台;margin/buffer 为小数) + profit_control_enabled?: boolean + profit_min_margin?: number + profit_safety_buffer?: number claude_code_only?: boolean fallback_group_id?: number | null fallback_group_id_on_invalid_request?: number | null diff --git a/frontend/src/views/admin/GroupsView.vue b/frontend/src/views/admin/GroupsView.vue index 0e328dbef06e..1b2ef8756689 100644 --- a/frontend/src/views/admin/GroupsView.vue +++ b/frontend/src/views/admin/GroupsView.vue @@ -1227,6 +1227,56 @@ + +
+ +

+ {{ + createForm.profit_control_enabled + ? t("admin.groups.profitControl.enabledHint") + : t("admin.groups.profitControl.disabledHint") + }} +

+
+
+ + +
+
+ + +
+
+
+
@@ -2222,51 +2272,6 @@ data-tour="group-form-multiplier" />
- -
-
- {{ t("admin.groups.retiredProfitControl.title") }} -
-

- {{ t("admin.groups.retiredProfitControl.hint") }} -

-
-
-
- {{ t("admin.groups.retiredProfitControl.enabled") }} - profit_control_enabled -
-
- {{ - editingGroup.profit_control_enabled - ? t("admin.groups.retiredProfitControl.on") - : t("admin.groups.retiredProfitControl.off") - }} -
-
-
-
- {{ t("admin.groups.retiredProfitControl.minMargin") }} - profit_min_margin -
-
- {{ editingGroup.profit_min_margin }} -
-
-
-
- {{ t("admin.groups.retiredProfitControl.safetyBuffer") }} - profit_safety_buffer -
-
- {{ editingGroup.profit_safety_buffer }} -
-
-
-
+ +
+ +

+ {{ + editForm.profit_control_enabled + ? t("admin.groups.profitControl.enabledHint") + : t("admin.groups.profitControl.disabledHint") + }} +

+
+
+ + +
+
+ + +
+
+
+
@@ -4300,6 +4355,13 @@ import { } from "./groupModelAllowlist"; import { createModelAllowlistCandidatesTracker } from "./modelAllowlistCandidates"; import { normalizeSupportedModelScopesForPlatform } from "./groupsSupportedModelScopes"; +import { + isProfitControlPlatform, + profitPercentToDecimal, + profitDecimalToPercent, + validateProfitControlFormState, + type ProfitControlFormState, +} from "./groupsProfitControl"; import { normalizeReasoningEffortForPlatform, normalizeReasoningEffortOverLimit, @@ -4928,6 +4990,10 @@ const createForm = reactive({ peak_start: "", peak_end: "", peak_rate_multiplier: 1.0, + // 分组利润控制(五个 token 平台);界面按百分比输入,提交时转小数 + profit_control_enabled: false, + profit_min_margin_percent: 0, + profit_safety_buffer_percent: 0, // Claude Code 客户端限制(仅 anthropic 平台使用) claude_code_only: false, fallback_group_id: null as number | null, @@ -5289,6 +5355,10 @@ const editForm = reactive({ peak_start: "", peak_end: "", peak_rate_multiplier: 1.0, + // 分组利润控制(五个 token 平台);界面按百分比输入,提交时转小数 + profit_control_enabled: false, + profit_min_margin_percent: 0, + profit_safety_buffer_percent: 0, // Claude Code 客户端限制(仅 anthropic 平台使用) claude_code_only: false, fallback_group_id: null as number | null, @@ -5743,6 +5813,9 @@ const closeCreateModal = () => { createForm.peak_start = ""; createForm.peak_end = ""; createForm.peak_rate_multiplier = 1.0; + createForm.profit_control_enabled = false; + createForm.profit_min_margin_percent = 0; + createForm.profit_safety_buffer_percent = 0; createForm.claude_code_only = false; createForm.fallback_group_id = null; createForm.fallback_group_id_on_invalid_request = null; @@ -5791,6 +5864,19 @@ const normalizeRateMultiplier = ( return Number.isFinite(parsed) && parsed >= 0 ? parsed : 1; }; +// 利润控制表单辅助(换算与校验逻辑见 groupsProfitControl.ts,便于单测)。 +const percentToDecimal = profitPercentToDecimal; +const decimalToPercent = profitDecimalToPercent; + +const validateProfitControlForm = (form: ProfitControlFormState): boolean => { + const errorKey = validateProfitControlFormState(form); + if (errorKey) { + appStore.showError(t(`admin.groups.profitControl.${errorKey}`)); + return false; + } + return true; +}; + const validateGroupReasoningMultipliers = (pricing: PricingFormEntry[]): boolean => { for (const entry of pricing) { const error = validateReasoningEffortMultipliers(entry.reasoning_effort_multipliers, t); @@ -5814,6 +5900,9 @@ const handleCreateGroup = async () => { ) { return; } + if (!validateProfitControlForm(createForm)) { + return; + } const modelAllowlist = buildModelAllowlistConfig(createModelAllowlistState); if (!validateGroupReasoningMultipliers(createForm.model_pricing)) return; // 校验本次会提交的配置;简单模式不提交隐藏的高级配置。 @@ -5884,7 +5973,17 @@ const handleCreateGroup = async () => { reasoning_effort_mappings: reasoningEffortMappingsToAPI( createForm.reasoning_effort_mappings, ), + // 利润控制:界面百分比转小数提交;仅五个 token 平台可启用 + profit_control_enabled: + isProfitControlPlatform(createForm.platform) && + createForm.profit_control_enabled, + profit_min_margin: percentToDecimal(createForm.profit_min_margin_percent), + profit_safety_buffer: percentToDecimal( + createForm.profit_safety_buffer_percent, + ), }; + delete (requestData as Record).profit_min_margin_percent; + delete (requestData as Record).profit_safety_buffer_percent; // v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制 const emptyToNull = (v: any) => (v === "" ? null : v); requestData.daily_limit_usd = emptyToNull(requestData.daily_limit_usd); @@ -6003,6 +6102,13 @@ const handleEdit = async (group: AdminGroup) => { editForm.peak_start = group.peak_start ?? ""; editForm.peak_end = group.peak_end ?? ""; editForm.peak_rate_multiplier = group.peak_rate_multiplier ?? 1.0; + editForm.profit_control_enabled = group.profit_control_enabled ?? false; + editForm.profit_min_margin_percent = decimalToPercent( + group.profit_min_margin ?? 0, + ); + editForm.profit_safety_buffer_percent = decimalToPercent( + group.profit_safety_buffer ?? 0, + ); editForm.claude_code_only = group.claude_code_only || false; editForm.fallback_group_id = group.fallback_group_id; editForm.fallback_group_id_on_invalid_request = @@ -6089,6 +6195,9 @@ const closeEditModal = () => { editForm.peak_start = ""; editForm.peak_end = ""; editForm.peak_rate_multiplier = 1.0; + editForm.profit_control_enabled = false; + editForm.profit_min_margin_percent = 0; + editForm.profit_safety_buffer_percent = 0; editForm.video_rate_independent = false; editForm.video_rate_multiplier = 1; editForm.video_price_480p = null; @@ -6125,6 +6234,9 @@ const handleUpdateGroup = async () => { ) { return; } + if (!validateProfitControlForm(editForm)) { + return; + } const modelAllowlist = buildModelAllowlistConfig(editModelAllowlistState); if (!validateGroupReasoningMultipliers(editForm.model_pricing)) return; // 校验本次会提交的配置;简单模式不提交隐藏的高级配置。 @@ -6212,7 +6324,17 @@ const handleUpdateGroup = async () => { reasoning_effort_mappings: reasoningEffortMappingsToAPI( editForm.reasoning_effort_mappings, ), + // 利润控制:界面百分比转小数提交;仅五个 token 平台可启用 + profit_control_enabled: + isProfitControlPlatform(editForm.platform) && + editForm.profit_control_enabled, + profit_min_margin: percentToDecimal(editForm.profit_min_margin_percent), + profit_safety_buffer: percentToDecimal( + editForm.profit_safety_buffer_percent, + ), }; + delete (payload as Record).profit_min_margin_percent; + delete (payload as Record).profit_safety_buffer_percent; // v-model.number 清空输入框时产生 "",转为 null 让后端设为无限制 const emptyToNull = (v: any) => (v === "" ? null : v); payload.daily_limit_usd = emptyToNull(payload.daily_limit_usd); @@ -6580,6 +6702,11 @@ watch( if (!supportsLivePlatform(newVal)) { createForm.allow_live = false; } + if (!isProfitControlPlatform(newVal)) { + createForm.profit_control_enabled = false; + createForm.profit_min_margin_percent = 0; + createForm.profit_safety_buffer_percent = 0; + } createForm.max_reasoning_effort = normalizeReasoningEffortForPlatform( newVal, createForm.max_reasoning_effort, @@ -6632,6 +6759,11 @@ watch( if (!supportsLivePlatform(newVal)) { editForm.allow_live = false; } + if (!isProfitControlPlatform(newVal)) { + editForm.profit_control_enabled = false; + editForm.profit_min_margin_percent = 0; + editForm.profit_safety_buffer_percent = 0; + } editForm.max_reasoning_effort = normalizeReasoningEffortForPlatform( newVal, editForm.max_reasoning_effort, diff --git a/frontend/src/views/admin/__tests__/groupsProfitControl.spec.ts b/frontend/src/views/admin/__tests__/groupsProfitControl.spec.ts new file mode 100644 index 000000000000..45a9262f5f20 --- /dev/null +++ b/frontend/src/views/admin/__tests__/groupsProfitControl.spec.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { + profitDecimalToPercent, + profitPercentToDecimal, + validateProfitControlFormState, + type ProfitControlFormState, +} from "../groupsProfitControl"; + +const formState = ( + overrides: Partial = {}, +): ProfitControlFormState => ({ + platform: "openai", + profit_control_enabled: true, + profit_min_margin_percent: 30, + profit_safety_buffer_percent: 0, + ...overrides, +}); + +describe("profitPercentToDecimal", () => { + it("converts percent input to backend decimal", () => { + expect(profitPercentToDecimal(30)).toBe(0.3); + expect(profitPercentToDecimal(5)).toBe(0.05); + expect(profitPercentToDecimal(33.33)).toBe(0.3333); + expect(profitPercentToDecimal(99.99)).toBe(0.9999); + }); + + it("rounds to four decimal places matching decimal(10,4) storage", () => { + expect(profitPercentToDecimal(33.333)).toBe(0.3333); + expect(profitPercentToDecimal(0.005)).toBe(0.0001); + }); + + it("treats empty, invalid and non-positive input as zero", () => { + expect(profitPercentToDecimal("")).toBe(0); + expect(profitPercentToDecimal(null)).toBe(0); + expect(profitPercentToDecimal(undefined)).toBe(0); + expect(profitPercentToDecimal("abc")).toBe(0); + expect(profitPercentToDecimal(-5)).toBe(0); + expect(profitPercentToDecimal(0)).toBe(0); + }); +}); + +describe("profitDecimalToPercent", () => { + it("converts backend decimal to percent without float tail noise", () => { + expect(profitDecimalToPercent(0.3)).toBe(30); + expect(profitDecimalToPercent(0.05)).toBe(5); + expect(profitDecimalToPercent(0.3333)).toBe(33.33); + expect(profitDecimalToPercent(0.9999)).toBe(99.99); + }); + + it("treats missing and non-positive values as zero", () => { + expect(profitDecimalToPercent(null)).toBe(0); + expect(profitDecimalToPercent(undefined)).toBe(0); + expect(profitDecimalToPercent(0)).toBe(0); + expect(profitDecimalToPercent(-0.3)).toBe(0); + }); + + it("round-trips representative storage values", () => { + for (const decimal of [0.05, 0.1, 0.3, 0.3333, 0.5, 0.75, 0.9999]) { + expect(profitPercentToDecimal(profitDecimalToPercent(decimal))).toBe( + decimal, + ); + } + }); +}); + +describe("validateProfitControlFormState", () => { + it("passes valid enabled configurations", () => { + expect(validateProfitControlFormState(formState())).toBeNull(); + expect( + validateProfitControlFormState( + formState({ + profit_min_margin_percent: 0, + profit_safety_buffer_percent: 0, + }), + ), + ).toBeNull(); + expect( + validateProfitControlFormState( + formState({ + profit_min_margin_percent: 60, + profit_safety_buffer_percent: 39.99, + }), + ), + ).toBeNull(); + }); + + it("validates all five supported platforms and skips unsupported platforms", () => { + expect( + validateProfitControlFormState( + formState({ profit_control_enabled: false, profit_min_margin_percent: 200 }), + ), + ).toBeNull(); + expect( + validateProfitControlFormState( + formState({ platform: "anthropic", profit_min_margin_percent: 200 }), + ), + ).toBe("marginRangeError"); + for (const platform of ["openai", "anthropic", "gemini", "grok", "antigravity"]) { + expect(validateProfitControlFormState(formState({ platform }))).toBeNull(); + } + expect( + validateProfitControlFormState( + formState({ platform: "composite", profit_min_margin_percent: 200 }), + ), + ).toBeNull(); + }); + + it("treats empty inputs as zero", () => { + expect( + validateProfitControlFormState( + formState({ + profit_min_margin_percent: "", + profit_safety_buffer_percent: null, + }), + ), + ).toBeNull(); + }); + + it("rejects out-of-range margin and buffer", () => { + expect( + validateProfitControlFormState( + formState({ profit_min_margin_percent: 100 }), + ), + ).toBe("marginRangeError"); + expect( + validateProfitControlFormState( + formState({ profit_min_margin_percent: -1 }), + ), + ).toBe("marginRangeError"); + expect( + validateProfitControlFormState( + formState({ profit_safety_buffer_percent: 100 }), + ), + ).toBe("bufferRangeError"); + expect( + validateProfitControlFormState( + formState({ profit_safety_buffer_percent: -0.1 }), + ), + ).toBe("bufferRangeError"); + }); + + it("rejects margin plus buffer reaching 100 percent", () => { + expect( + validateProfitControlFormState( + formState({ + profit_min_margin_percent: 60, + profit_safety_buffer_percent: 40, + }), + ), + ).toBe("sumTooHigh"); + }); + + // 上界按换算后的小数判定:99.999% 会被 profitPercentToDecimal 四舍五入 + // 进位成 1.0,后端 validProfitControlRatio 要求严格 < 1,前端必须先拦下。 + it("rejects percents that round up to a decimal the backend rejects", () => { + expect(profitPercentToDecimal(99.999)).toBe(1); + expect( + validateProfitControlFormState( + formState({ profit_min_margin_percent: 99.999 }), + ), + ).toBe("marginRangeError"); + expect( + validateProfitControlFormState( + formState({ + profit_min_margin_percent: 0, + profit_safety_buffer_percent: 99.999, + }), + ), + ).toBe("bufferRangeError"); + }); + + it("still accepts the largest percent that survives the rounding", () => { + expect(profitPercentToDecimal(99.99)).toBe(0.9999); + expect( + validateProfitControlFormState( + formState({ profit_min_margin_percent: 99.99 }), + ), + ).toBeNull(); + }); +}); diff --git a/frontend/src/views/admin/groupsProfitControl.ts b/frontend/src/views/admin/groupsProfitControl.ts new file mode 100644 index 000000000000..71166e820886 --- /dev/null +++ b/frontend/src/views/admin/groupsProfitControl.ts @@ -0,0 +1,69 @@ +// 分组利润控制表单辅助:百分比 <-> 小数换算与提交前校验。 +// 后端按小数存 decimal(10,4)(0.30 = 30%),界面按百分比输入展示; +// 固定 4 位小数精度,避免 0.3 * 100 = 30.000000000000004 之类的浮点尾数回显。 + +export const profitPercentToDecimal = ( + value: number | string | null | undefined, +): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 0; + } + return Math.round(parsed * 100) / 10000; +}; + +export const profitDecimalToPercent = ( + value: number | null | undefined, +): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 0; + } + return Math.round(parsed * 1e6) / 1e4; +}; + +export type ProfitControlFormState = { + platform: string; + profit_control_enabled: boolean; + profit_min_margin_percent: number | string | null; + profit_safety_buffer_percent: number | string | null; +}; + +export const isProfitControlPlatform = (platform: string): boolean => + ["openai", "anthropic", "gemini", "grok", "antigravity"].includes(platform); + +// 提交前校验:margin/buffer 各自 ∈ [0,1),且相加 < 1(否则阈值 <= 0, +// 所有可核价账号都会被排除)。返回 null 表示通过,否则返回错误信息的 i18n key +//(相对 admin.groups.profitControl 前缀)。仅支持平台且开关开启时才校验。 +// +// 上界校验必须落在 profitPercentToDecimal 的换算结果上,而不是界面百分比: +// 后端 validProfitControlRatio 校验的是小数 [0,1),而 99.999% 会被四舍五入 +// 进位成 1.0——按百分比判 `< 100` 会让前端放行、后端 400。 +export const validateProfitControlFormState = ( + form: ProfitControlFormState, +): string | null => { + if (!isProfitControlPlatform(form.platform) || !form.profit_control_enabled) { + return null; + } + const marginPercent = Number(form.profit_min_margin_percent || 0); + const bufferPercent = Number(form.profit_safety_buffer_percent || 0); + if (!Number.isFinite(marginPercent) || marginPercent < 0) { + return "marginRangeError"; + } + if (!Number.isFinite(bufferPercent) || bufferPercent < 0) { + return "bufferRangeError"; + } + // 校验实际提交给后端的小数值,边界按构造对齐。 + const margin = profitPercentToDecimal(marginPercent); + const buffer = profitPercentToDecimal(bufferPercent); + if (margin >= 1) { + return "marginRangeError"; + } + if (buffer >= 1) { + return "bufferRangeError"; + } + if (margin + buffer >= 1) { + return "sumTooHigh"; + } + return null; +}; From af9c1eb843c7cb51924317d1b2d716a57d1e31a1 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 18:13:58 +0800 Subject: [PATCH 03/39] docs(downstream): record that group profit control is upstream's again The v0.2.13 review listed the profit control kill switch among the deviations kept. It now states the restoration, the files that are identical to upstream again, the auth snapshot version, why no migration is needed, what a group without a gate still sees differently (the snapshot reload and the two counters in the legacy selection summary), the profit lines that still differ inside downstream mechanisms and the checks that were run. Co-Authored-By: Claude Opus 5.5 --- .downstream/upstream-review-v0.2.13.md | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.downstream/upstream-review-v0.2.13.md b/.downstream/upstream-review-v0.2.13.md index cc02577bdef2..27489848f2a4 100644 --- a/.downstream/upstream-review-v0.2.13.md +++ b/.downstream/upstream-review-v0.2.13.md @@ -23,6 +23,13 @@ Every official commit is adopted with its official behaviour. Where upstream and - **Account priority quick adjust (upstream #7630).** Adopted: the stepper and inline input in the accounts table save a priority-only update 450 ms after the last change, without confirmation, and revert on failure. Downstream table rows open the details drawer on a click, so the cell stops click propagation; without it every stepper click also opened the drawer. - **Security fixes.** Adopted: the anonymous `POST /api/v1/payment/public/orders/verify` is limited to 20 requests per minute per client IP and fails open on Redis errors (#7673); the Antigravity Gemini path returns a scrubbed Gemini-style error body to the client (#7674); verification-code attempts are counted atomically and password reset tokens are stored as SHA-256 hashes and consumed once (#7676). A reset link issued by the previous version stops working after the upgrade, because its stored token is not a hash; a new reset email is needed. - **Also adopted as upstream:** Grok CLI identity headers and version gate (#7780), usage settlement when the API key was deleted before billing completes (#7816), API keys sortable by group (#7803), the corrected custom-error-code warning text (#7773), `.github/SECURITY.md` (#7814). +- **Group profit control (upstream #4925) is upstream's again.** Earlier releases kept the feature switched off: a compile-time kill switch (`profitControlRuntimeEnabled`), a 400 on any `profit_*` field in group create and update, the constants `false/0/0` in the API-key auth snapshot and a read-only block in the group edit dialog. All of it is removed (owner decision of 2026-10-03). Groups carry `profit_control_enabled`, `profit_min_margin` and `profit_safety_buffer` as upstream does, editable in the create and edit dialogs, off by default. A group that leaves it off gets no gate, so nothing is filtered for it. + - Identical to v0.2.13 again: `service/openai_profit_control.go`, `service/gateway_profit_control.go`, `service/group.go`, `service/admin_group_duplicate.go`, `handler/admin/group_handler.go`, the `profit-preview` command with `service/profit_preview.go`, `views/admin/groupsProfitControl.ts` with its spec, and upstream's profit tests in `service`, `handler` and `handler/dto`. The downstream rewrites of those tests to the switched-off state, `profit_control_dormant_regression_test.go` and `TestGroupHandlerRejectsLegacyProfitControlWrites` are gone. + - Restored inside files the fork also changes: the editable section and its form logic in `GroupsView.vue` (in place of the read-only block), the `profitControl` texts of both locales (in place of `retiredProfitControl`), the request and response types, the stored values in `snapshotFromAPIKey` and `snapshotToAPIKey`, the two profit counters of the legacy selection failure summary in `gateway_scheduling.go`, and upstream's comments at the gate call sites. The summary therefore ends with `profit_threshold=0 profit_invalid_account_rate=0` for a group without a gate; it is logged, and a client reads it in the `No available accounts` message only where `gateway.scheduling.load_batch_enabled` is off. + - Auth snapshot version 28. Entries written by earlier releases hold the constants, not the stored values, so every cached key is reloaded once after the upgrade, as after each earlier version change. + - No migration. The three columns come from 192, and `enqueue_group_auth_cache_invalidation` as last defined by 260 has the body of 193, which compares the three columns. + - Console theme: no rule and no hook was added. The section has the markup of the peak-rate block above it, and its divider, checkbox, label, hint, field labels and inputs resolve to the same computed styles as their neighbours in light and dark. + - A group whose stored `profit_control_enabled` is true is gated from the first start of this release; the stored values were not read here. - **Console theme for the new upstream UI.** Behaviour is upstream's; only styling was added under `frontend/src/styles/console`: the eleventh platform segment of the account dialog takes the `sky` identity glyph (`controls.css` 7.1a); the priority control sits as the value of the stacked priority line with label-toned steppers (`pages/accounts.css` 5, reached through the component's own static `data-testid` attributes); the mode toggle of `RechargeBonusTierEditor` carries the static `data-ui="seg-box"` hook. No `:has()`, no runtime-bound hook. - One existing rule also reaches the new UI without a change: the `RechargeBonusTierEditor` card has the same header and `p-4` body as the payment provider card beside it, so the positional body rule of `pages/settings-ops-public.css` 2.2 gives it the settings card padding. The comment of that rule now names both cards. @@ -47,7 +54,15 @@ Every official commit is adopted with its official behaviour. Where upstream and - **Generic platform lists.** `api/admin/settings.ts`, `api/admin/users.ts`, `UserPlatformQuotaCell.vue`, `UserDashboardStats.vue` and `SettingsView.vue` keep the lists derived from `CONCRETE_PLATFORM_VALUES`. The dashboard cards therefore follow the platform catalog order (TypeSafe after the other known platforms, unknown platforms last); upstream's new dashboard test encoded its literal order and was rewritten to the catalog order. - **Capacity stays manifest-derived.** Upstream adds no capacity literal in this range and none was added for `jev-latest`. - **Codex catalogs of composite groups exclude TypeSafe**, as upstream: `compositeAvailableModels(..., includeSystemOne=false)` and the static composite defaults never list `jev-latest`. A group whose own platform is `typesafe` is not covered by that exclusion, also as upstream: `GET /v1/models?client_version=...` reaches `GatewayHandler.CodexModels` for every non-OpenAI group, and its manifest lists `jev-latest` (from the account mappings or `defaultModelIDsForPlatform`), with no capacity unless an account override or observation supplies one. -- Image Studio and the Responses image bridge stay off, the public model page stays retired, the profit control kill switch and the per-account reasoning policy switches are untouched; no upstream change in this range touches them. +- **Profit control inside downstream mechanisms.** The profit code differs from v0.2.13 only where a downstream contract wraps it: + - a selection result also carries the runtime-breaker probe (`attachSelectionProfitGate(ctx, attachSelectionRuntimeBreakerProbe(...))`), and a vetoed slot releases that probe together with the slot; + - the previous-response lookup reads the stored owner from the repository once, so the veto is checked once instead of upstream's twice, and a veto is the downstream policy miss: reuse is skipped, the binding stays, ordinary scheduling follows; + - a Codex quality run is pinned to its account: a veto answers 409 `codex_quality_unavailable` instead of reselecting, and the run binds no sticky session; + - the WebSocket turn veto closes the turn with the downstream local close error; + - token counting selects through the scheduler under the handler's suppress marker, so upstream's `SelectAccountForTokenCount` is not used; + - the eligibility helpers return `(bool, reason)` for the downstream selection diagnostics, and the handlers pass the pricing instant through the downstream usage snapshot; + - the auth snapshot version is the downstream counter (28; upstream 24). +- Image Studio and the Responses image bridge stay off, the public model page stays retired and the per-account reasoning policy switches are untouched; no upstream change in this range touches them. ## Conflict resolutions @@ -89,10 +104,11 @@ Every official commit is adopted with its official behaviour. Where upstream and - The frontend checks ran against a shared dependency tree that holds axios 1.18.1, while the lockfile pins 1.20.0. - After the review fixes (System One Ops body, connection test success rule, `cindy` in the 241 quota list, the CSS comment), on the final tree: `go build ./...` (windows) and `go vet -tags=unit` on `pkg/typesafe`, `service` and `migrations` pass, and `gofmt -l` is empty for the six changed Go files. Focused tests pass: `pkg/typesafe` (`TestEvaluateContract` and both `DecodeSystemOneResponse` tests), `migrations` (both TypeSafe migration tests), `service` (the five TypeSafe connection tests; the `ForwardSystemOne` error policy, request error, account-level failover, oversized response and response-without-model tests; the new Ops record test). `golangci-lint` 2.13 reports 0 issues on `pkg/typesafe`, `migrations` and `service`. Not repeated: the linux build, the `integration` and `e2e` vet runs, the other packages and every frontend check. The fixes changed no platform-specific code, no tagged test file and no other package, and in the frontend only a CSS comment. - After merging `origin/main` (`291d25e7f`, #233): one conflict, `backend/internal/service/admin_account.go`, where upstream's TypeSafe API-key-only check stays and the `maps.Clone(accountExtra)` line that #233 removed stays removed. `.downstream/upstream-risk.json` was regenerated against the new downstream commit (1420 downstream paths; 157 upstream, 65 overlapping and 31 critical as before). On that tree `go build ./...` and `go vet -tags=unit` on `service`, `handler`, `repository`, `server` and `migrations` pass, the two TypeSafe migration tests and the `service` tests matching `TypeSafe` pass, and `test_upstream_risk.py` and `test_upstream_automation_contract.py` pass. No frontend check was repeated locally. +- Profit control, on top of the integration: `go build ./...` (windows) passes; `go vet -tags=unit` passes on `service`, `handler`, `handler/admin`, `handler/dto` and `cmd/profit-preview`; `gofmt -l` is empty for the changed Go files; `golangci-lint` 2.13 reports 0 issues on the same five packages. Upstream's profit tests pass unchanged against the downstream scheduler: 45 in `service` (gate resolution, veto reasons, scheduler and legacy filtering, previous-response reuse, pricing context, the shared-gateway gate, auth snapshot roundtrip and eviction, preview), 10 in `handler` (veto budget, failover loop, slot recheck), 2 in `handler/dto`, 3 in `cmd/profit-preview`. Also passing: the two simple-mode group tests, the other auth snapshot tests, the selection failure statistics tests, the group duplicate tests and the group handler tests. `vue-tsc --noEmit` passes; vitest passes for `groupsProfitControl.spec.ts`, the four `GroupsView` specs and locale key completeness; ESLint passes on the changed frontend files. A local preview against a mock API showed the section in the create and edit dialogs in light and dark (off, on with both numbers, the sum error, a subscription group with the peak-rate block, 390 px); the captures are not part of the repository. Not run for this change: the linux build, the `integration` and `e2e` vet runs, and the two database-backed profit tests in `repository`, which are unchanged upstream files. - Not run: full suites, race tests, integration tests against a database, a new-database, older-database or production migration rehearsal, real model or TypeSafe calls, deploy. Source integration does not claim a successful deployment. ## Release target -- `v0.2.13-codexrip.1`, fixed downstream image, ordinary `deploy-preserve` (natural drain, only the Sub2API container recreated, runtime configuration kept). The first start applies three migrations: the two upstream 241 files and 265. No setting changes on upgrade: no recharge tier and no TypeSafe account or group exists until an administrator creates one. +- `v0.2.13-codexrip.1`, fixed downstream image, ordinary `deploy-preserve` (natural drain, only the Sub2API container recreated, runtime configuration kept). The first start applies three migrations: the two upstream 241 files and 265. No setting changes on upgrade: no recharge tier and no TypeSafe account or group exists until an administrator creates one, and profit control stays off for every group that has not enabled it. - Not part of this integration: the system prompt binding selector of the account edit dialog is shown for TypeSafe accounts although System One requests are never injected. The owner decided on 2026-10-03 that it is not offered there; the follow-up change does that. - Final source SHA, PR and check results, image digest and production outcome belong to the operational delivery evidence. From b6badacd83f02492788f33babf48516c75a46055 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 17:42:42 +0800 Subject: [PATCH 04/39] =?UTF-8?q?feat(reasoning):=20add=20the=20=E6=8E=A8?= =?UTF-8?q?=E7=90=86=E6=81=A2=E5=A4=8D=20page=20and=20drop=20the=20per-acc?= =?UTF-8?q?ount=20reasoning=20options?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invalid reasoning ciphertext recovery becomes one global switch on its own page under 扩展功能; the two per-account options leave the account dialogs. This is the frontend half: the backend for the endpoint below and the removal of the per-account backend code follow separately. - /admin/reasoning-recovery (sidebar: 扩展功能, after Codex 运行设置): one switch with Reload and Save, no step-up. It reads and writes GET/PUT /api/v1/admin/reasoning-recovery, body and answer {"enabled": boolean}. No value is shown before one is loaded, and an answer without a boolean counts as a failed request. - Create, edit and bulk edit of an account no longer show or send openai_chat_reasoning_replay_enabled and openai_reasoning_signature_recovery_enabled. OpenAIReasoningPolicyFields, utils/openaiReasoningPolicy, their specs and locale keys are deleted, and buildOpenAIExtra starts from a plain copy again. - oauthPassthroughDesc now names the global switch instead of the option that used to sit below it. - Console theme: the page takes the 系统提示词 rules through its own data-ui hook; no new rule set. - downstream-verify selects the new view spec in place of the two deleted specs, which test_upstream_automation_contract requires to exist. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/downstream-verify.yml | 3 +- frontend/src/api/admin/index.ts | 4 + frontend/src/api/admin/reasoningRecovery.ts | 14 ++ .../account/BulkEditAccountModal.vue | 35 ----- .../components/account/CreateAccountModal.vue | 22 +-- .../components/account/EditAccountModal.vue | 27 ---- .../account/OpenAIReasoningPolicyFields.vue | 71 ---------- .../__tests__/BulkEditAccountModal.spec.ts | 52 ------- .../__tests__/CreateAccountModal.spec.ts | 43 ------ .../__tests__/EditAccountModal.spec.ts | 51 ------- .../OpenAIReasoningPolicyFields.spec.ts | 43 ------ frontend/src/components/layout/AppSidebar.vue | 1 + .../__tests__/AppSidebar.simple-mode.spec.ts | 4 +- .../useModelContextCapacities.spec.ts | 4 +- .../src/i18n/locales/en/admin/accounts.ts | 10 +- frontend/src/i18n/locales/en/admin/index.ts | 2 + .../locales/en/admin/reasoningRecovery.ts | 14 ++ frontend/src/i18n/locales/en/common.ts | 1 + .../src/i18n/locales/zh/admin/accounts.ts | 10 +- frontend/src/i18n/locales/zh/admin/index.ts | 2 + .../locales/zh/admin/reasoningRecovery.ts | 14 ++ frontend/src/i18n/locales/zh/common.ts | 1 + frontend/src/router/index.ts | 6 + .../console/pages/settings-ops-public.css | 38 +++--- frontend/src/styles/console/pages/user.css | 5 +- .../__tests__/openaiReasoningPolicy.spec.ts | 47 ------- frontend/src/utils/openaiReasoningPolicy.ts | 59 -------- .../src/views/admin/ReasoningRecoveryView.vue | 83 +++++++++++ .../__tests__/ReasoningRecoveryView.spec.ts | 129 ++++++++++++++++++ 29 files changed, 304 insertions(+), 491 deletions(-) create mode 100644 frontend/src/api/admin/reasoningRecovery.ts delete mode 100644 frontend/src/components/account/OpenAIReasoningPolicyFields.vue delete mode 100644 frontend/src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts create mode 100644 frontend/src/i18n/locales/en/admin/reasoningRecovery.ts create mode 100644 frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts delete mode 100644 frontend/src/utils/__tests__/openaiReasoningPolicy.spec.ts delete mode 100644 frontend/src/utils/openaiReasoningPolicy.ts create mode 100644 frontend/src/views/admin/ReasoningRecoveryView.vue create mode 100644 frontend/src/views/admin/__tests__/ReasoningRecoveryView.spec.ts diff --git a/.github/workflows/downstream-verify.yml b/.github/workflows/downstream-verify.yml index f4b4e4b57720..f943aaf7c8ca 100644 --- a/.github/workflows/downstream-verify.yml +++ b/.github/workflows/downstream-verify.yml @@ -144,8 +144,7 @@ jobs: src/components/account/__tests__/CreateAccountModal.spec.ts \ src/components/payment/__tests__/StripePaymentInline.contrast.spec.ts \ src/views/user/__tests__/StripePaymentView.spec.ts \ - src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts \ - src/utils/__tests__/openaiReasoningPolicy.spec.ts \ + src/views/admin/__tests__/ReasoningRecoveryView.spec.ts \ src/components/account/__tests__/EditAccountModal.spec.ts \ src/utils/__tests__/accountEditCodec.spec.ts \ src/components/account/__tests__/ModelWhitelistSelector.spec.ts \ diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 28873f8ed410..ce27e583846d 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -37,6 +37,7 @@ import adminComplianceAPI from './compliance' import auditAPI from './audit' import pluginsAPI from './plugins' import systemPromptsAPI from './systemPrompts' +import reasoningRecoveryAPI from './reasoningRecovery' import accountJobsAPI from './accountJobs' /** @@ -77,6 +78,7 @@ export const adminAPI = { audit: auditAPI, plugins: pluginsAPI, systemPrompts: systemPromptsAPI, + reasoningRecovery: reasoningRecoveryAPI, accountJobs: accountJobsAPI } @@ -115,6 +117,7 @@ export { auditAPI, pluginsAPI, systemPromptsAPI, + reasoningRecoveryAPI, accountJobsAPI } @@ -129,3 +132,4 @@ export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl' export type { PluginInstallation, PluginCompatibility, PluginUISession, PluginTestResult } from './plugins' export type { SystemPrompt, SystemPromptBinding, SystemPromptConfig, SystemPromptState } from './systemPrompts' +export type { ReasoningRecoveryConfig } from './reasoningRecovery' diff --git a/frontend/src/api/admin/reasoningRecovery.ts b/frontend/src/api/admin/reasoningRecovery.ts new file mode 100644 index 000000000000..a6d42e92342c --- /dev/null +++ b/frontend/src/api/admin/reasoningRecovery.ts @@ -0,0 +1,14 @@ +import { apiClient } from '../client' + +export interface ReasoningRecoveryConfig { + enabled: boolean +} + +const basePath = '/admin/reasoning-recovery' + +export const reasoningRecoveryAPI = { + get: async () => (await apiClient.get(basePath)).data, + save: async (config: ReasoningRecoveryConfig) => (await apiClient.put(basePath, config)).data +} + +export default reasoningRecoveryAPI diff --git a/frontend/src/components/account/BulkEditAccountModal.vue b/frontend/src/components/account/BulkEditAccountModal.vue index 001668cb5720..73e3f7b138cf 100644 --- a/frontend/src/components/account/BulkEditAccountModal.vue +++ b/frontend/src/components/account/BulkEditAccountModal.vue @@ -31,14 +31,6 @@

- -
p === 'grok') ) const isMixedPlatform = computed(() => targetSelectedPlatforms.value.length > 1) -const allOpenAIReasoningPolicyCapable = computed(() => - targetSelectedPlatforms.value.length > 0 && - targetSelectedTypes.value.length > 0 && - targetSelectedPlatforms.value.every(platform => - targetSelectedTypes.value.every(type => isOpenAIReasoningPolicyApplicable({ platform, type })) - ) -) const allOpenAIPassthroughCapable = computed(() => { return ( @@ -1724,8 +1702,6 @@ const rateMultiplier = ref(1) const status = ref<'active' | 'inactive'>('active') const groupIds = ref([]) const openaiPassthroughEnabled = ref(false) -const openAIReasoningPolicy = ref(defaultOpenAIReasoningPolicy()) -const openAIReasoningPolicySelected = ref(emptyOpenAIReasoningPolicySelection()) // Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 const openaiFlattenNamespacesEnabled = ref(false) const openAILongContextBillingEnabled = ref(false) @@ -2017,13 +1993,6 @@ const buildUpdatePayload = (): Record | null => { } } - if (allOpenAIReasoningPolicyCapable.value && - (openAIReasoningPolicySelected.value.chatReplay || openAIReasoningPolicySelected.value.signatureRecovery)) { - updates.extra = applyOpenAIReasoningPolicyEdits( - ensureExtra(), openAIReasoningPolicy.value, openAIReasoningPolicySelected.value - ) - } - // 同时校验可见性:勾选后又改了目标筛选条件时,不应把该键写到非 OAuth 账号上 if (enableOpenAIFlattenNamespaces.value && allOpenAIOAuthOnly.value) { const extra = ensureExtra() @@ -2240,8 +2209,6 @@ const handleSubmit = async () => { } const hasAnyFieldEnabled = - (allOpenAIReasoningPolicyCapable.value && - (openAIReasoningPolicySelected.value.chatReplay || openAIReasoningPolicySelected.value.signatureRecovery)) || enableBaseUrl.value || enableOpenAIPassthrough.value || enableOpenAIFlattenNamespaces.value || @@ -2400,8 +2367,6 @@ watch( // Reset all values baseUrl.value = '' openaiPassthroughEnabled.value = false - openAIReasoningPolicy.value = defaultOpenAIReasoningPolicy() - openAIReasoningPolicySelected.value = emptyOpenAIReasoningPolicySelection() openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false openAIEndpointCapabilities.value = ['chat_completions', 'embeddings'] diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 080371258831..640ac4dfecd0 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -3183,13 +3183,6 @@
- -
) => { const interceptWarmupRequests = ref(false) const autoPauseOnExpired = ref(true) const openaiPassthroughEnabled = ref(false) -const openAIReasoningPolicy = ref(defaultOpenAIReasoningPolicy()) -const openAIReasoningPolicySelected = ref(emptyOpenAIReasoningPolicySelection()) // OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留 const openaiFlattenNamespacesEnabled = ref(false) const openAILongContextBillingEnabled = ref(false) @@ -5068,8 +5052,6 @@ watch( codexCLIOnlyEnabled.value = false codexCLIOnlyAppServerEnabled.value = false } - openAIReasoningPolicy.value = defaultOpenAIReasoningPolicy() - openAIReasoningPolicySelected.value = emptyOpenAIReasoningPolicySelection() if (newPlatform !== 'anthropic') { anthropicPassthroughEnabled.value = false anthropicAPIKeyAuthScheme.value = 'x_api_key' @@ -5582,8 +5564,6 @@ const resetForm = () => { interceptWarmupRequests.value = false autoPauseOnExpired.value = true openaiPassthroughEnabled.value = false - openAIReasoningPolicy.value = defaultOpenAIReasoningPolicy() - openAIReasoningPolicySelected.value = emptyOpenAIReasoningPolicySelection() openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false openAILongContextBillingTouched.value = false @@ -5655,7 +5635,7 @@ const buildOpenAIExtra = (base?: Record): Record = { ...(base || {}) } if (accountCategory.value === 'oauth-based') { extra.openai_oauth_responses_websockets_v2_mode = openaiOAuthResponsesWebSocketV2Mode.value extra.openai_oauth_responses_websockets_v2_enabled = isOpenAIWSModeEnabled(openaiOAuthResponsesWebSocketV2Mode.value) diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 8a645a361441..14e4f185b972 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1937,13 +1937,6 @@
- -
props.account?.platform === 'grok' && props.account?.type === 'oauth' ) @@ -4282,8 +4265,6 @@ const syncFormFromAccount = (newAccount: Account | null) => { upstreamBillingAutoProbeEnabled.value && extra?.upstream_billing_rate_sync_enabled === true // Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key) - openAIReasoningPolicy.value = readOpenAIReasoningPolicy(extra) - openAIReasoningPolicySelected.value = emptyOpenAIReasoningPolicySelection() openaiPassthroughEnabled.value = false openaiFlattenNamespacesEnabled.value = false openAILongContextBillingEnabled.value = false @@ -6015,14 +5996,6 @@ const handleSubmit = async () => { updatePayload.extra = newExtra } - if (isOpenAIReasoningPolicyApplicable(props.account)) { - updatePayload.extra = applyOpenAIReasoningPolicyEdits( - (updatePayload.extra as Record | undefined) ?? props.account.extra, - openAIReasoningPolicy.value, - openAIReasoningPolicySelected.value - ) - } - if (updatePayload.extra) { updatePayload.extra = withoutManagedCapacityExtra(updatePayload.extra as Record) } diff --git a/frontend/src/components/account/OpenAIReasoningPolicyFields.vue b/frontend/src/components/account/OpenAIReasoningPolicyFields.vue deleted file mode 100644 index 34b906efb4ae..000000000000 --- a/frontend/src/components/account/OpenAIReasoningPolicyFields.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts index d6b37ba206d4..c36e5d2870d5 100644 --- a/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/BulkEditAccountModal.spec.ts @@ -100,58 +100,6 @@ describe('BulkEditAccountModal', () => { } as any) }) - it('reasoning policy omits unselected switches when changing another field', async () => { - const wrapper = mountModal({ selectedPlatforms: ['openai'], selectedTypes: ['apikey'] }) - await wrapper.get('#bulk-edit-openai-passthrough-enabled').setValue(true) - await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') - await flushPromises() - expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { - extra: { openai_passthrough: false, openai_oauth_passthrough: false } - }) - wrapper.unmount() - }) - - it('reasoning policy applies only a selected switch and accepts explicit false', async () => { - const wrapper = mountModal({ selectedPlatforms: ['openai'], selectedTypes: ['oauth', 'setup-token', 'apikey'] }) - await wrapper.get('[data-testid="openai-reasoning-signatureRecovery-selected"]').setValue(true) - await wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]').trigger('click') - await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') - await flushPromises() - expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], { - extra: { openai_reasoning_signature_recovery_enabled: false } - }) - wrapper.unmount() - }) - - it('reasoning policy applies explicit true to OpenAI filter targets', async () => { - const wrapper = mountModal({ - selectedPlatforms: ['anthropic'], - selectedTypes: ['apikey'], - target: { mode: 'filtered', previewCount: 4, selectedPlatforms: ['openai'], selectedTypes: ['apikey'], filters: { type: 'apikey' } } - }) - await wrapper.get('[data-testid="openai-reasoning-chatReplay-selected"]').setValue(true) - await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') - await flushPromises() - expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1) - expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith({ - filters: { type: 'apikey' }, - extra: { openai_chat_reasoning_replay_enabled: true } - }) - wrapper.unmount() - }) - - it('reasoning policy drops stale selections if the bulk target becomes inapplicable', async () => { - const wrapper = mountModal({ selectedPlatforms: ['openai'], selectedTypes: ['apikey'] }) - await wrapper.get('[data-testid="openai-reasoning-chatReplay-selected"]').setValue(true) - await wrapper.setProps({ selectedPlatforms: ['grok'] }) - expect(wrapper.find('[data-testid="openai-reasoning-policy"]').exists()).toBe(false) - await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent') - await flushPromises() - expect(adminAPI.accounts.bulkUpdate).not.toHaveBeenCalled() - expect(showError).toHaveBeenCalledWith('admin.accounts.bulkEdit.noFieldsSelected') - wrapper.unmount() - }) - it('does not expose account-level compatibility selectors for bulk editing', () => { const wrapper = mountModal({ selectedPlatforms: ['openai'], diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 131a3f84c1dc..f3306ab0e5f0 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -526,49 +526,6 @@ describe('CreateAccountModal OpenAI long-context billing', () => { wrapper.unmount() }) - it('reasoning policy defaults on without writing untouched switches during creation', async () => { - const wrapper = await submitApiKeyAccount('openai') - expect(wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').attributes('aria-checked')).toBe('true') - expect(wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]').attributes('aria-checked')).toBe('true') - expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_chat_reasoning_replay_enabled') - expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') - wrapper.unmount() - }) - - it('reasoning policy creates an explicit independent opt-out', async () => { - const wrapper = mountModal() - await selectButtonByText(wrapper, 'OpenAI') - await selectButtonByText(wrapper, 'API Key') - await wrapper.get('form#create-account-form input[type="text"]').setValue('openai account') - await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key') - await wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]').trigger('click') - await wrapper.get('form#create-account-form').trigger('submit.prevent') - await flushPromises() - expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_reasoning_signature_recovery_enabled).toBe(false) - expect(createAccountMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_chat_reasoning_replay_enabled') - wrapper.unmount() - }) - - it('reasoning policy preserves untouched import settings and forwards explicit edits', async () => { - const untouched = await openCodexImportStep() - await untouched.get('[data-testid="import-codex-session"]').trigger('click') - await flushPromises() - expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_chat_reasoning_replay_enabled') - expect(importCodexSessionMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') - untouched.unmount() - - const wrapper = mountModal() - await selectButtonByText(wrapper, 'OpenAI') - await wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').trigger('click') - await wrapper.get('form#create-account-form input[type="text"]').setValue('Codex import') - await wrapper.get('form#create-account-form').trigger('submit.prevent') - await wrapper.get('[data-testid="import-codex-pat"]').trigger('click') - await flushPromises() - expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra?.openai_chat_reasoning_replay_enabled).toBe(false) - expect(createOpenAICodexPATMock.mock.calls[0]?.[0]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') - wrapper.unmount() - }) - it('submits the Codex fingerprint mode of an OAuth-based account, explicit off included, and none for an API-key account', async () => { const untouched = await openCodexImportStep() await untouched.get('[data-testid="import-codex-session"]').trigger('click') diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index 849342cdedb5..acb8b49b4654 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -663,57 +663,6 @@ describe('EditAccountModal', () => { } }) - it.each(['apikey', 'oauth', 'setup-token'])('reasoning policy defaults on and omits untouched edits for %s', async (type) => { - const account = { ...buildAccount(), type } - updateAccountMock.mockReset().mockResolvedValue(account) - checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) - const wrapper = mountModal(account) - expect(wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').attributes('aria-checked')).toBe('true') - expect(wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]').attributes('aria-checked')).toBe('true') - await wrapper.get('form#edit-account-form').trigger('submit.prevent') - expect(updateAccountMock).toHaveBeenCalledTimes(1) - expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_chat_reasoning_replay_enabled') - expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') - wrapper.unmount() - }) - - it('reasoning policy preserves explicit false and malformed historical values until changed', async () => { - const account = buildAccount() - account.extra = { openai_chat_reasoning_replay_enabled: false, openai_reasoning_signature_recovery_enabled: 'invalid-old-value' } - updateAccountMock.mockReset().mockResolvedValue(account) - checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) - const wrapper = mountModal(account) - expect(wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').attributes('aria-checked')).toBe('false') - expect(wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]').attributes('aria-checked')).toBe('false') - await wrapper.get('form#edit-account-form').trigger('submit.prevent') - expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_chat_reasoning_replay_enabled') - expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') - expect(account.extra.openai_reasoning_signature_recovery_enabled).toBe('invalid-old-value') - wrapper.unmount() - }) - - it('reasoning policy allows an explicit enable without overwriting the other switch', async () => { - const account = buildAccount() - account.extra = { openai_chat_reasoning_replay_enabled: false, openai_reasoning_signature_recovery_enabled: false } - updateAccountMock.mockReset().mockResolvedValue(account) - checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) - const wrapper = mountModal(account) - await wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').trigger('click') - await wrapper.get('form#edit-account-form').trigger('submit.prevent') - expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.openai_chat_reasoning_replay_enabled).toBe(true) - expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('openai_reasoning_signature_recovery_enabled') - wrapper.unmount() - }) - - it('reasoning policy is available for OpenAI accounts but not native Grok', () => { - const openai = mountModal(buildAccount()) - expect(openai.find('[data-testid="openai-reasoning-policy"]').exists()).toBe(true) - openai.unmount() - const grok = mountModal(buildGrokAPIKeyAccount()) - expect(grok.find('[data-testid="openai-reasoning-policy"]').exists()).toBe(false) - grok.unmount() - }) - it('does not expose account-level compatibility selectors', () => { const ordinaryWrapper = mountModal(buildAccount()) expect(ordinaryWrapper.find('[data-testid="openai-alpha-search-mode-select"]').exists()).toBe(false) diff --git a/frontend/src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts b/frontend/src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts deleted file mode 100644 index 391b52c56be8..000000000000 --- a/frontend/src/components/account/__tests__/OpenAIReasoningPolicyFields.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { mount } from '@vue/test-utils' -import OpenAIReasoningPolicyFields from '../OpenAIReasoningPolicyFields.vue' -import { defaultOpenAIReasoningPolicy, emptyOpenAIReasoningPolicySelection } from '@/utils/openaiReasoningPolicy' - -vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) - -describe('OpenAI reasoning policy fields', () => { - it('renders native controls without requiring an installed plugin', async () => { - const wrapper = mount(OpenAIReasoningPolicyFields, { props: { modelValue: defaultOpenAIReasoningPolicy(), selected: emptyOpenAIReasoningPolicySelection(), idPrefix: 'availability' } }) - expect(wrapper.find('[data-testid="openai-reasoning-policy"]').exists()).toBe(true) - const toggle = wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]') - expect(toggle.attributes('disabled')).toBeUndefined() - await toggle.trigger('click') - expect(wrapper.emitted('update:modelValue')).toHaveLength(1) - }) - it('exposes independent accessible switches and tracks only deliberate edits', async () => { - const wrapper = mount(OpenAIReasoningPolicyFields, { - props: { modelValue: defaultOpenAIReasoningPolicy(), selected: emptyOpenAIReasoningPolicySelection(), idPrefix: 'test' } - }) - expect(wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').attributes('aria-checked')).toBe('true') - expect(wrapper.find('input[type="checkbox"]').exists()).toBe(false) - await wrapper.get('[data-testid="openai-reasoning-chatReplay-toggle"]').trigger('click') - expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([{ chatReplay: false, signatureRecovery: true }]) - expect(wrapper.emitted('update:selected')?.[0]).toEqual([{ chatReplay: true, signatureRecovery: false }]) - expect(wrapper.text()).toContain('admin.accounts.openai.reasoningPolicy.boundaryHint') - }) - - it('does not allow bulk toggles to change an unselected setting', async () => { - const wrapper = mount(OpenAIReasoningPolicyFields, { - props: { modelValue: defaultOpenAIReasoningPolicy(), selected: emptyOpenAIReasoningPolicySelection(), idPrefix: 'bulk', bulk: true } - }) - const toggle = wrapper.get('[data-testid="openai-reasoning-signatureRecovery-toggle"]') - expect(toggle.attributes('disabled')).toBeDefined() - await toggle.trigger('click') - expect(wrapper.emitted('update:modelValue')).toBeUndefined() - await wrapper.get('[data-testid="openai-reasoning-signatureRecovery-selected"]').setValue(true) - expect(wrapper.emitted('update:selected')?.[0]).toEqual([{ chatReplay: false, signatureRecovery: true }]) - await wrapper.setProps({ selected: { chatReplay: false, signatureRecovery: true } }) - await toggle.trigger('click') - expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([{ chatReplay: true, signatureRecovery: false }]) - }) -}) diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index af61bed833b9..e3de07be00e1 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -838,6 +838,7 @@ function buildExtensionNavItems(includeAdmin: boolean): NavItem[] { if (includeAdmin) { items.push({ path: '/admin/system-prompts', label: t('nav.systemPrompts'), icon: DocumentIcon }) items.push({ path: '/admin/codex-runtime', label: t('nav.codexRuntime'), icon: PluginIcon }) + items.push({ path: '/admin/reasoning-recovery', label: t('nav.reasoningRecovery'), icon: ShieldIcon }) } items.push({ path: '/image-studio', label: t('nav.imageStudio'), icon: BatchImageIcon, featureFlag: flagImageStudio }) return items diff --git a/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts b/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts index b53043c260e2..76845e7edf19 100644 --- a/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts +++ b/frontend/src/components/layout/__tests__/AppSidebar.simple-mode.spec.ts @@ -109,14 +109,14 @@ describe('AppSidebar simple mode extensions', () => { it('renders admin extensions and Image Studio for an admin in simple mode', async () => { const wrapper = await renderSidebar({ admin: true, imageStudio: true }) - expect(extensionLinks(wrapper)).toEqual(['/admin/system-prompts', '/admin/codex-runtime', '/image-studio']) + expect(extensionLinks(wrapper)).toEqual(['/admin/system-prompts', '/admin/codex-runtime', '/admin/reasoning-recovery', '/image-studio']) expect(wrapper.text()).not.toContain('nav.myAccount') }) it('keeps admin extensions visible while hiding Image Studio when its flag is disabled', async () => { const wrapper = await renderSidebar({ admin: true, imageStudio: false }) - expect(extensionLinks(wrapper)).toEqual(['/admin/system-prompts', '/admin/codex-runtime']) + expect(extensionLinks(wrapper)).toEqual(['/admin/system-prompts', '/admin/codex-runtime', '/admin/reasoning-recovery']) wrapper.unmount() }) }) diff --git a/frontend/src/composables/__tests__/useModelContextCapacities.spec.ts b/frontend/src/composables/__tests__/useModelContextCapacities.spec.ts index 47a0c87c7d3c..9918da7a95e3 100644 --- a/frontend/src/composables/__tests__/useModelContextCapacities.spec.ts +++ b/frontend/src/composables/__tests__/useModelContextCapacities.spec.ts @@ -588,10 +588,10 @@ describe('useModelContextCapacities', () => { model_context_overrides: { 'real-model': 1_000_000 }, upstream_model_context_capacities: { old: true }, upstream_model_metadata: { old: true }, - openai_reasoning_signature_recovery_enabled: false, + openai_long_context_billing_enabled: false, nested: { keep: true } } - expect(withoutManagedCapacityExtra(extra)).toEqual({ openai_reasoning_signature_recovery_enabled: false, nested: { keep: true } }) + expect(withoutManagedCapacityExtra(extra)).toEqual({ openai_long_context_billing_enabled: false, nested: { keep: true } }) expect(extra).toHaveProperty('model_context_overrides') expect(extra).toHaveProperty('upstream_model_context_capacities') expect(extra).toHaveProperty('upstream_model_metadata') diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index 36d883bea105..aa3211fec19c 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -837,15 +837,7 @@ export default {accounts: { apiKeyHint: 'Your OpenAI API Key', oauthPassthrough: 'Auto passthrough (auth only)', oauthPassthroughDesc: - 'When enabled, this OpenAI account forwards requests/responses as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering. Enabled signature recovery below is an explicit exception and is recorded as lossy recovery.', - reasoningPolicy: { - chatReplay: 'Replay reasoning for Chat tool turns', - chatReplayDesc: 'Enabled by default. Cache completed function-tool batches that can be represented faithfully in Chat for 24 hours. Restore raw reasoning and tool items only when account, user, model, reasoning configuration and history all match. Cache failures do not block requests; full input conversations are not cached.', - signatureRecovery: 'Recover invalid reasoning ciphertext (lossy)', - signatureRecoveryDesc: 'Enabled by default. After an explicit signature rejection and before semantic output, remove only the rejected reasoning ciphertext and retry the same account at most once. Remember that old ciphertext for 24 hours without removing new reasoning. This also applies as a passthrough exception, may incur extra upstream charges, and does not restore the original reasoning chain.', - applyField: 'Apply this setting (otherwise keep existing value)', - boundaryHint: 'Only actual HTTP/SSE native Responses, Compact and Chat-to-Responses paths are eligible; replay is Chat-to-Responses only. Messages, WebSocket (including HTTP ingress routed to WS), and native Chat-only upstreams do not use this recovery. The 24-hour limit is logical; Redis persistence files and backups may retain historical bytes.' - }, + 'When enabled, this OpenAI account forwards requests/responses as-is and only swaps auth, while keeping billing/concurrency/audit and necessary safety filtering. The global Reasoning Recovery switch under Extensions still applies to passthrough accounts as an explicit exception.', flattenNamespaces: 'Flatten Codex namespace tools (compatibility)', flattenNamespacesDesc: 'Disabled by default: Codex namespace tool declarations are forwarded as-is on /responses, which is what the ChatGPT Codex backend expects. Enable only when this OAuth account is routed to a relay that rejects namespace tools — flattening renames them to namespace__tool, which breaks models that address collaboration tools as functions... Compaction requests always flatten regardless of this switch.', diff --git a/frontend/src/i18n/locales/en/admin/index.ts b/frontend/src/i18n/locales/en/admin/index.ts index 4a4564ce91b6..78f8eeb5d16a 100644 --- a/frontend/src/i18n/locales/en/admin/index.ts +++ b/frontend/src/i18n/locales/en/admin/index.ts @@ -7,6 +7,7 @@ import settings from './settings' import audit from './audit' import promptAudit from './promptAudit' import systemPrompts from './systemPrompts' +import reasoningRecovery from './reasoningRecovery' import accountTasks from './accountTasks' import plugins from './plugins' @@ -20,6 +21,7 @@ export default { ...audit, ...promptAudit, ...systemPrompts, + ...reasoningRecovery, ...accountTasks, ...plugins, } diff --git a/frontend/src/i18n/locales/en/admin/reasoningRecovery.ts b/frontend/src/i18n/locales/en/admin/reasoningRecovery.ts new file mode 100644 index 000000000000..a61156b9fa44 --- /dev/null +++ b/frontend/src/i18n/locales/en/admin/reasoningRecovery.ts @@ -0,0 +1,14 @@ +export default { + reasoningRecovery: { + title: 'Reasoning Recovery', + description: 'Whether the gateway recovers automatically when an OpenAI upstream rejects a request because its reasoning ciphertext is no longer valid. Applies to all OpenAI accounts.', + enabled: 'Recover invalid reasoning ciphertext', + enabledHint: 'When on, after an explicit signature-verification error and before any semantic output has been sent, the gateway strips only the ciphertext field of the rejected reasoning items, retries at most once more on the same account, and remembers that old ciphertext for 24 hours; new reasoning is not removed. Auto-passthrough accounts are covered as well. This may add upstream charges and does not restore the original reasoning chain. When off, such requests return the error directly.', + scope: 'Applies only to requests actually sent over HTTP/SSE on the native Responses, Compact and Chat-to-Responses paths. Messages, WebSocket (including HTTP ingress routed to WS) and native Chat-only upstreams are not covered. The 24 hours are a logical limit; Redis persistence files and backups may retain historical bytes.', + save: 'Save', + saved: 'Reasoning recovery setting saved', + reload: 'Refresh', + loadFailed: 'Loading failed', + saveFailed: 'Saving failed' + } +} diff --git a/frontend/src/i18n/locales/en/common.ts b/frontend/src/i18n/locales/en/common.ts index f2e71830e03c..3eff85ca53a8 100644 --- a/frontend/src/i18n/locales/en/common.ts +++ b/frontend/src/i18n/locales/en/common.ts @@ -224,6 +224,7 @@ export default { promptAudit: 'Prompt Audit', auditLogs: 'Audit Logs', systemPrompts: 'System Prompts', + reasoningRecovery: 'Reasoning Recovery', }, // Auth diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 28ec6eb0fc03..8e9482c2c890 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -983,15 +983,7 @@ export default {accounts: { apiKeyHint: '您的 OpenAI API Key', oauthPassthrough: '自动透传(仅替换认证)', oauthPassthroughDesc: - '开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;下方已开启的失效密文恢复是明确例外,会留下有损恢复记录。', - reasoningPolicy: { - chatReplay: 'Chat 工具回合推理回注', - chatReplayDesc: '默认开启。将已完整完成、可无损投影为 Chat 的 function 工具批次缓存 24 小时;仅在账号、用户、模型、推理配置和历史完全匹配时回注原始 reasoning 与工具项。缓存故障不阻断请求,不缓存全会话输入。', - signatureRecovery: '失效推理密文恢复(有损)', - signatureRecoveryDesc: '默认开启。遇到明确验签错误且尚未输出语义内容时,只剥离被拒 reasoning 的密文字段,在同账号最多额外重试一次;记忆该旧密文 24 小时,不删除新推理。包括自动透传的恢复例外,可能增加调用费用,不代表恢复原推理链。', - applyField: '修改此项(未勾选则保持原值)', - boundaryHint: '仅适用于实际 HTTP/SSE 的原生 Responses、Compact 与 Chat→Responses 路径;回注只用于 Chat→Responses。Messages、WebSocket(包括 HTTP 入口转 WS)、原生 Chat-only 上游不启用本轮恢复。24 小时是逻辑期限,Redis 持久化文件与备份可能保留历史字节。' - }, + '开启后,该 OpenAI 账号将自动透传请求与响应,仅替换认证并保留计费/并发/审计及必要安全过滤;“扩展功能 → 推理恢复”的全局开关对透传账号同样生效,是明确例外。', flattenNamespaces: '摊平 Codex namespace 工具(兼容)', flattenNamespacesDesc: '默认关闭:/responses 上的 namespace 工具声明原样转发,这正是 ChatGPT Codex 后端期望的形态。仅当该 OAuth 账号指向不认识 namespace 的兼容上游时才开启——摊平会把工具改名为 namespace__tool,使按 functions.<命名空间>.<工具> 寻址的模型(如 gpt-5.6 多智能体)无法调用。压缩(compact)请求不受该开关影响,始终摊平。', diff --git a/frontend/src/i18n/locales/zh/admin/index.ts b/frontend/src/i18n/locales/zh/admin/index.ts index 4a4564ce91b6..78f8eeb5d16a 100644 --- a/frontend/src/i18n/locales/zh/admin/index.ts +++ b/frontend/src/i18n/locales/zh/admin/index.ts @@ -7,6 +7,7 @@ import settings from './settings' import audit from './audit' import promptAudit from './promptAudit' import systemPrompts from './systemPrompts' +import reasoningRecovery from './reasoningRecovery' import accountTasks from './accountTasks' import plugins from './plugins' @@ -20,6 +21,7 @@ export default { ...audit, ...promptAudit, ...systemPrompts, + ...reasoningRecovery, ...accountTasks, ...plugins, } diff --git a/frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts b/frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts new file mode 100644 index 000000000000..d8290fc2574c --- /dev/null +++ b/frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts @@ -0,0 +1,14 @@ +export default { + reasoningRecovery: { + title: '推理恢复', + description: 'OpenAI 上游拒绝请求中已失效的推理密文时,网关是否自动恢复。对全部 OpenAI 账号生效。', + enabled: '失效推理密文恢复', + enabledHint: '开启后,遇到明确的验签错误且尚未输出语义内容时,只剥离被拒 reasoning 的密文字段,在同一账号最多额外重试一次,并记忆该旧密文 24 小时;不删除新推理。自动透传账号同样适用。可能增加调用费用,不代表恢复原推理链。关闭后,这类请求直接返回错误。', + scope: '仅适用于实际 HTTP/SSE 的原生 Responses、Compact 与 Chat→Responses 路径。Messages、WebSocket(包括 HTTP 入口转 WS)、原生 Chat-only 上游不启用。24 小时是逻辑期限,Redis 持久化文件与备份可能保留历史字节。', + save: '保存', + saved: '推理恢复设置已保存', + reload: '刷新', + loadFailed: '加载失败', + saveFailed: '保存失败' + } +} diff --git a/frontend/src/i18n/locales/zh/common.ts b/frontend/src/i18n/locales/zh/common.ts index dd4980eec40b..49836cf6b474 100644 --- a/frontend/src/i18n/locales/zh/common.ts +++ b/frontend/src/i18n/locales/zh/common.ts @@ -224,6 +224,7 @@ export default { promptAudit: '提示词审计', auditLogs: '操作日志', systemPrompts: '系统提示词', + reasoningRecovery: '推理恢复', }, // Auth diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1a36474ca597..1efb0dc84f17 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -580,6 +580,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/admin/CodexRuntimeView.vue'), meta: { requiresAuth: true, requiresAdmin: true, title: 'Codex runtime settings', titleKey: 'nav.codexRuntime' } }, + { + path: '/admin/reasoning-recovery', + name: 'AdminReasoningRecovery', + component: () => import('@/views/admin/ReasoningRecoveryView.vue'), + meta: { requiresAuth: true, requiresAdmin: true, title: 'Reasoning Recovery', titleKey: 'nav.reasoningRecovery' } + }, { path: '/admin/announcements', name: 'AdminAnnouncements', diff --git a/frontend/src/styles/console/pages/settings-ops-public.css b/frontend/src/styles/console/pages/settings-ops-public.css index 982d505a5f98..dab13126a5d7 100644 --- a/frontend/src/styles/console/pages/settings-ops-public.css +++ b/frontend/src/styles/console/pages/settings-ops-public.css @@ -2384,7 +2384,7 @@ html.flat-theme [data-ui="key-usage"] .skeleton { /* end public */ /* settings: 系统设置 (every tab, its sub-panels and dialogs), 系统提示词 (the library and the per-account binding - * views), Codex 运行设置 and 插件管理. Owner: wave 2 P3 (the settings page agent). + * views), Codex 运行设置, 推理恢复 and 插件管理. Owner: wave 2 P3 (the settings page agent). * * Source: the reference console's settings language (artifacts/tmp/ui-el/ref/console/REF-CONSOLE.md §5, §8.3-§8.5, * §8.11, §8.16 and its public recipes: Modal, ConnectModal, DemoBanner, PromoField, TopUpModal, Toggle, AddChoice) @@ -2398,6 +2398,8 @@ html.flat-theme [data-ui="key-usage"] .skeleton { * Template hooks (one attribute each, upstream markup otherwise unchanged): * views/admin/SettingsView.vue:3 data-ui="settings" (page root) * views/admin/SystemPromptsView.vue:3 data-ui="system-prompts" (page root, fork view) + * views/admin/ReasoningRecoveryView.vue:3 data-ui="reasoning-recovery" (page root, fork view; it shares + * the 系统提示词 rules for the head, the card and the switch line) * components/admin/codex/CodexRuntimeSettings.vue:2 data-ui="codex-runtime" (page root, fork component) * views/admin/PluginsView.vue:3 data-ui="plugins" (page root) * views/admin/BackupView.vue:291 data-ui="r2-guide" (the R2 guide dialog's outer layer) @@ -2415,7 +2417,7 @@ html.flat-theme [data-ui="key-usage"] .skeleton { * Selectors start with html.flat-theme and one of those roots; the theme switched off renders upstream. */ /* ================================================================== settings · 0 metrics */ -html.flat-theme :is([data-ui~="settings"], [data-ui~="system-prompts"], [data-ui~="codex-runtime"], [data-ui~="plugins"]) { +html.flat-theme :is([data-ui~="settings"], [data-ui~="system-prompts"], [data-ui~="reasoning-recovery"], [data-ui~="codex-runtime"], [data-ui~="plugins"]) { /* the reference Modal's header (px 18, py 14) and body (px 18, py 16) insets */ --set-px: 1.125rem; --set-head-py: 0.875rem; @@ -2439,14 +2441,14 @@ html.flat-theme [data-ui~="settings"] > form > div:not([class]) > [class~="space margin-top: var(--ui-shell-gap); } -/* The centred narrow pages (settings max-w-6xl, the prompt library max-w-5xl) start at the content gutter like every - console page of the reference, under the header row's title; their maximum width stays (long forms keep a readable - measure; the space on the right of a wide screen is canvas). */ -html.flat-theme :is([data-ui~="settings"], [data-ui~="system-prompts"])[class~="mx-auto"] { +/* The centred narrow pages (settings max-w-6xl; the prompt library and 推理恢复 max-w-5xl) start at the content gutter + like every console page of the reference, under the header row's title; their maximum width stays (long forms keep + a readable measure; the space on the right of a wide screen is canvas). */ +html.flat-theme :is([data-ui~="settings"], [data-ui~="system-prompts"], [data-ui~="reasoning-recovery"])[class~="mx-auto"] { margin-inline: 0; } -html.flat-theme [data-ui~="system-prompts"][class~="px-1"] { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"])[class~="px-1"] { padding-inline: 0; } @@ -3034,11 +3036,12 @@ html.flat-theme [data-ui~="settings"] .card div[class~="overflow-x-auto"] > tabl /* ================================================================== 7 · page heads of the fork pages and plugins */ /* 7.1 The page's own heading repeats the header row's title word for word (系统提示词 in Chinese — its route title is - not translated —, Codex 运行设置, 插件管理), and the plugins intro repeats the header's description word for word. - From 1024px, where the header row shows both, they are kept for assistive technology only; below that the header - hides its title and description and the page's own stay. */ + not translated —, 推理恢复, Codex 运行设置, 插件管理), and the plugins intro repeats the header's description word for + word. From 1024px, where the header row shows both, they are kept for assistive technology only; below that the + header hides its title and description and the page's own stay. */ @media (min-width: 1024px) { html.flat-theme:lang(zh) [data-ui~="system-prompts"] > header h1, + html.flat-theme [data-ui~="reasoning-recovery"] > header h1, html.flat-theme [data-ui~="codex-runtime"] > h2:first-child, html.flat-theme [data-ui~="plugins"] > section:first-child h2, html.flat-theme [data-ui~="plugins"] > section:first-child > div:first-child > p { @@ -3053,7 +3056,7 @@ html.flat-theme [data-ui~="settings"] .card div[class~="overflow-x-auto"] > tabl /* 7.2 The intro under the title: the reference's page intro (13px on a relaxed line, muted, 780px at most); a wrapped intro keeps more than one character on its last line. */ -html.flat-theme [data-ui~="system-prompts"] > header p, +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > header p, html.flat-theme [data-ui~="codex-runtime"] > h2:first-child + p, html.flat-theme [data-ui~="plugins"] > section:first-child > div:first-child > p { max-width: 48.75rem; @@ -3085,20 +3088,23 @@ html.flat-theme :is([data-ui~="system-prompts"] > header h1, [data-ui~="codex-ru /* ================================================================== 8 · 系统提示词 (SystemPromptsView, fork) */ +/* 推理恢复 (ReasoningRecoveryView, fork) is the same page with one card: a head, then a switch line over its notes. + 8.1 and the switch title and note sizes of 8.2 cover both roots. */ + /* 8.1 Rhythm: the head, the switch section and the library sit one block gap apart (the reference's 12). The head is an intro line with its actions, no rule under it; each section is a section card (surface, structural line, radius 8, 16 x 18). */ -html.flat-theme [data-ui~="system-prompts"] > :not([hidden]) ~ :not([hidden]) { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > :not([hidden]) ~ :not([hidden]) { margin-top: var(--ui-shell-gap); } -html.flat-theme [data-ui~="system-prompts"] > header { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > header { align-items: flex-end; border-bottom: 0; padding-bottom: 0; } -html.flat-theme [data-ui~="system-prompts"] > section { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > section { border: 1px solid rgb(var(--ui-line)); border-radius: var(--ui-radius-lg); background-color: rgb(var(--ui-surface)); @@ -3107,7 +3113,7 @@ html.flat-theme [data-ui~="system-prompts"] > section { /* 8.2 The switch line (switch, title, hint on one row) and the default prompt: title 13 / 600 ink, hint 12 muted; the field label is the mono label; the usage line in ink-soft with its figures tabular; the notes 12 muted. */ -html.flat-theme [data-ui~="system-prompts"] > section:first-of-type > label:first-child > span[class~="font-medium"] { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > section:first-of-type > label:first-child > span[class~="font-medium"] { font-weight: 600; color: rgb(var(--ui-ink)); } @@ -3145,7 +3151,7 @@ html.flat-theme [data-ui~="system-prompts"] [data-test="system-prompts-usage"] { color: rgb(var(--ui-ink-soft)); } -html.flat-theme [data-ui~="system-prompts"] > section > p[class~="text-xs"] { +html.flat-theme :is([data-ui~="system-prompts"], [data-ui~="reasoning-recovery"]) > section > p[class~="text-xs"] { font-size: 0.75rem; line-height: 1.125rem; } diff --git a/frontend/src/styles/console/pages/user.css b/frontend/src/styles/console/pages/user.css index c6aa7cd44f64..35b648012291 100644 --- a/frontend/src/styles/console/pages/user.css +++ b/frontend/src/styles/console/pages/user.css @@ -1703,9 +1703,10 @@ html.flat-theme [data-ui="user-orders"] > .table-wrapper .table-body > tr > td[c @media (max-width: 1023.98px) { /* 8.0 Below 1024px the 47px bar prints the page title (shell.css), so a page's own heading that repeats it word for word is kept for assistive technology only, as the pages already do from 1024px: 系统提示词 (its route title is - Chinese), Codex 运行设置, 插件管理, 风控中心, 提示词审计, 渠道监控 (the image studio: 7.2). The intros under them - stay. */ + Chinese), 推理恢复, Codex 运行设置, 插件管理, 风控中心, 提示词审计, 渠道监控 (the image studio: 7.2). The intros + under them stay. */ html.flat-theme:lang(zh) [data-ui~="system-prompts"] > header h1, + html.flat-theme [data-ui~="reasoning-recovery"] > header h1, html.flat-theme [data-ui~="codex-runtime"] > h2:first-child, html.flat-theme [data-ui~="plugins"] > section:first-child h2, html.flat-theme [data-ui~="risk-page"] h1[class~="text-2xl"], diff --git a/frontend/src/utils/__tests__/openaiReasoningPolicy.spec.ts b/frontend/src/utils/__tests__/openaiReasoningPolicy.spec.ts deleted file mode 100644 index ab84c8934841..000000000000 --- a/frontend/src/utils/__tests__/openaiReasoningPolicy.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - OPENAI_CHAT_REASONING_REPLAY_KEY, - OPENAI_REASONING_SIGNATURE_RECOVERY_KEY, - applyOpenAIReasoningPolicyEdits, - defaultOpenAIReasoningPolicy, - emptyOpenAIReasoningPolicySelection, - isOpenAIReasoningPolicyApplicable, - readOpenAIReasoningPolicy -} from '../openaiReasoningPolicy' - -describe('OpenAI reasoning policy', () => { - it('matches OpenAI accounts of the accepted account types', () => { - for (const type of ['apikey', 'oauth', 'setup-token']) { - expect(isOpenAIReasoningPolicyApplicable({ platform: 'openai', type })).toBe(true) - } - for (const platform of ['anthropic', 'grok', 'gemini']) { - expect(isOpenAIReasoningPolicyApplicable({ platform, type: 'apikey' })).toBe(false) - } - expect(isOpenAIReasoningPolicyApplicable({ platform: 'openai', type: 'service_account' })).toBe(false) - expect(isOpenAIReasoningPolicyApplicable(null)).toBe(false) - }) - - it('defaults absent keys on but treats malformed historical values as off', () => { - expect(readOpenAIReasoningPolicy(null)).toEqual(defaultOpenAIReasoningPolicy()) - for (const value of [false, null, undefined, 'true', 1, {}, []]) { - expect(readOpenAIReasoningPolicy({ [OPENAI_CHAT_REASONING_REPLAY_KEY]: value })) - .toEqual({ chatReplay: false, signatureRecovery: true }) - } - expect(readOpenAIReasoningPolicy({ [OPENAI_REASONING_SIGNATURE_RECOVERY_KEY]: true })) - .toEqual(defaultOpenAIReasoningPolicy()) - }) - - it('sends only selected edits and never replays stale policy snapshots', () => { - const base = { - unrelated_setting: 'preserved', - [OPENAI_CHAT_REASONING_REPLAY_KEY]: 'bad-old-value', - [OPENAI_REASONING_SIGNATURE_RECOVERY_KEY]: false - } - expect(applyOpenAIReasoningPolicyEdits(base, defaultOpenAIReasoningPolicy(), emptyOpenAIReasoningPolicySelection())) - .toEqual({ unrelated_setting: 'preserved' }) - expect(applyOpenAIReasoningPolicyEdits(base, { chatReplay: false, signatureRecovery: true }, { chatReplay: true, signatureRecovery: false })) - .toEqual({ unrelated_setting: 'preserved', [OPENAI_CHAT_REASONING_REPLAY_KEY]: false }) - expect(base[OPENAI_CHAT_REASONING_REPLAY_KEY]).toBe('bad-old-value') - expect(base[OPENAI_REASONING_SIGNATURE_RECOVERY_KEY]).toBe(false) - }) -}) diff --git a/frontend/src/utils/openaiReasoningPolicy.ts b/frontend/src/utils/openaiReasoningPolicy.ts deleted file mode 100644 index 1c64af2f34bf..000000000000 --- a/frontend/src/utils/openaiReasoningPolicy.ts +++ /dev/null @@ -1,59 +0,0 @@ -export const OPENAI_CHAT_REASONING_REPLAY_KEY = 'openai_chat_reasoning_replay_enabled' -export const OPENAI_REASONING_SIGNATURE_RECOVERY_KEY = 'openai_reasoning_signature_recovery_enabled' - -export interface OpenAIReasoningPolicy { - chatReplay: boolean - signatureRecovery: boolean -} - -export type OpenAIReasoningPolicyField = keyof OpenAIReasoningPolicy - -interface ReasoningPolicyAccount { - platform?: unknown - type?: unknown -} - -const policyKeys = { - chatReplay: OPENAI_CHAT_REASONING_REPLAY_KEY, - signatureRecovery: OPENAI_REASONING_SIGNATURE_RECOVERY_KEY -} as const - -export function isOpenAIReasoningPolicyApplicable(account: ReasoningPolicyAccount | null | undefined): boolean { - if (!account || !['apikey', 'oauth', 'setup-token'].includes(String(account.type))) return false - return typeof account.platform === 'string' && account.platform.trim().toLowerCase() === 'openai' -} - -export function defaultOpenAIReasoningPolicy(): OpenAIReasoningPolicy { - return { chatReplay: true, signatureRecovery: true } -} - -export function emptyOpenAIReasoningPolicySelection(): OpenAIReasoningPolicy { - return { chatReplay: false, signatureRecovery: false } -} - -export function readOpenAIReasoningPolicy(extra: Record | null | undefined): OpenAIReasoningPolicy { - const read = (key: string): boolean => { - if (!extra || !Object.prototype.hasOwnProperty.call(extra, key)) return true - return extra[key] === true - } - return { - chatReplay: read(OPENAI_CHAT_REASONING_REPLAY_KEY), - signatureRecovery: read(OPENAI_REASONING_SIGNATURE_RECOVERY_KEY) - } -} - -// The admin update API preserves omitted policy keys. Send only deliberate edits, -// never replay a modal's stale snapshot (including malformed historical values). -export function applyOpenAIReasoningPolicyEdits( - extra: Record | null | undefined, - values: OpenAIReasoningPolicy, - selected: OpenAIReasoningPolicy -): Record { - const result = { ...extra } - for (const field of Object.keys(policyKeys) as OpenAIReasoningPolicyField[]) { - const key = policyKeys[field] - delete result[key] - if (selected[field]) result[key] = values[field] - } - return result -} diff --git a/frontend/src/views/admin/ReasoningRecoveryView.vue b/frontend/src/views/admin/ReasoningRecoveryView.vue new file mode 100644 index 000000000000..5d8c52c683dd --- /dev/null +++ b/frontend/src/views/admin/ReasoningRecoveryView.vue @@ -0,0 +1,83 @@ + + + diff --git a/frontend/src/views/admin/__tests__/ReasoningRecoveryView.spec.ts b/frontend/src/views/admin/__tests__/ReasoningRecoveryView.spec.ts new file mode 100644 index 000000000000..f983cebbc041 --- /dev/null +++ b/frontend/src/views/admin/__tests__/ReasoningRecoveryView.spec.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' + +import ReasoningRecoveryView from '../ReasoningRecoveryView.vue' + +const { get, put, showSuccess, useStepUp } = vi.hoisted(() => ({ + get: vi.fn(), + put: vi.fn(), + showSuccess: vi.fn(), + useStepUp: vi.fn(), +})) + +vi.mock('@/api/client', () => ({ apiClient: { get, put } })) +vi.mock('@/stores/app', () => ({ useAppStore: () => ({ showSuccess }) })) +vi.mock('@/composables/useStepUp', () => ({ useStepUp })) +vi.mock('vue-i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ t: (key: string) => key }), +})) + +const path = '/admin/reasoning-recovery' + +async function mountView() { + const wrapper = mount(ReasoningRecoveryView, { + global: { stubs: { AppLayout: { template: '
' } } }, + }) + await flushPromises() + return wrapper +} + +type View = Awaited> +const toggle = (wrapper: View) => wrapper.get('[data-test="reasoning-recovery-enabled"]') +const saveButton = (wrapper: View) => wrapper.get('[data-test="reasoning-recovery-save"]') + +describe('ReasoningRecoveryView', () => { + beforeEach(() => { + get.mockReset().mockResolvedValue({ data: { enabled: true } }) + put.mockReset().mockImplementation(async (_url: string, value: unknown) => ({ data: value })) + showSuccess.mockReset() + useStepUp.mockReset() + }) + + it('shows no switch until the stored value is loaded, then enables Save only while the draft differs', async () => { + let answer!: (value: { data: { enabled: boolean } }) => void + get.mockReturnValueOnce(new Promise((resolve) => { answer = resolve })) + const wrapper = await mountView() + + expect(get).toHaveBeenCalledWith(path) + expect(wrapper.text()).toContain('common.loading') + expect(wrapper.find('[data-test="reasoning-recovery-enabled"]').exists()).toBe(false) + expect(saveButton(wrapper).element.disabled).toBe(true) + + answer({ data: { enabled: true } }) + await flushPromises() + expect(wrapper.text()).not.toContain('common.loading') + expect(toggle(wrapper).attributes('aria-checked')).toBe('true') + expect(saveButton(wrapper).element.disabled).toBe(true) + + await toggle(wrapper).trigger('click') + expect(toggle(wrapper).attributes('aria-checked')).toBe('false') + expect(saveButton(wrapper).element.disabled).toBe(false) + + await toggle(wrapper).trigger('click') + expect(saveButton(wrapper).element.disabled).toBe(true) + expect(put).not.toHaveBeenCalled() + }) + + it('saves the draft with one PUT and no step-up', async () => { + const wrapper = await mountView() + await toggle(wrapper).trigger('click') + await saveButton(wrapper).trigger('click') + await flushPromises() + + expect(put).toHaveBeenCalledTimes(1) + expect(put).toHaveBeenCalledWith(path, { enabled: false }) + expect(useStepUp).not.toHaveBeenCalled() + expect(wrapper.findComponent({ name: 'TotpStepUpDialog' }).exists()).toBe(false) + expect(showSuccess).toHaveBeenCalledWith('admin.reasoningRecovery.saved') + expect(toggle(wrapper).attributes('aria-checked')).toBe('false') + expect(saveButton(wrapper).element.disabled).toBe(true) + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + }) + + it('replaces the draft with the value the server stored', async () => { + put.mockResolvedValue({ data: { enabled: true } }) + const wrapper = await mountView() + await toggle(wrapper).trigger('click') + await saveButton(wrapper).trigger('click') + await flushPromises() + + expect(put).toHaveBeenCalledWith(path, { enabled: false }) + expect(toggle(wrapper).attributes('aria-checked')).toBe('true') + expect(saveButton(wrapper).element.disabled).toBe(true) + }) + + it('reports a failed or unusable load without showing a value, and loads again on Reload', async () => { + get.mockRejectedValueOnce({ message: 'load refused' }) + const wrapper = await mountView() + + expect(wrapper.get('[role="alert"]').text()).toBe('load refused') + expect(wrapper.find('[data-test="reasoning-recovery-enabled"]').exists()).toBe(false) + expect(saveButton(wrapper).element.disabled).toBe(true) + + get.mockResolvedValueOnce({ data: {} }) + await wrapper.get('[data-test="reasoning-recovery-reload"]').trigger('click') + await flushPromises() + expect(wrapper.get('[role="alert"]').text()).toBe('admin.reasoningRecovery.loadFailed') + expect(wrapper.find('[data-test="reasoning-recovery-enabled"]').exists()).toBe(false) + + get.mockResolvedValueOnce({ data: { enabled: false } }) + await wrapper.get('[data-test="reasoning-recovery-reload"]').trigger('click') + await flushPromises() + expect(wrapper.find('[role="alert"]').exists()).toBe(false) + expect(toggle(wrapper).attributes('aria-checked')).toBe('false') + }) + + it('reports a failed save and keeps the draft', async () => { + put.mockRejectedValue({ message: 'save refused' }) + const wrapper = await mountView() + await toggle(wrapper).trigger('click') + await saveButton(wrapper).trigger('click') + await flushPromises() + + expect(wrapper.get('[role="alert"]').text()).toBe('save refused') + expect(showSuccess).not.toHaveBeenCalled() + expect(toggle(wrapper).attributes('aria-checked')).toBe('false') + expect(saveButton(wrapper).element.disabled).toBe(false) + }) +}) From c6e5b86c6c7c409a13afbb5688916493a05d9f5c Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 18:12:57 +0800 Subject: [PATCH 05/39] chore(settings): delete the two retired API-key compatibility settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openai_apikey_alpha_search_responses_bridge_enabled and openai_apikey_prompt_cache_key_normalization_enabled lost their gates and write fields on 2026-09-01 (cce166e21). Since then the values were only loaded, shown in 系统设置 as 已弃用设置(只读,不生效) and written back unchanged on every save. They now go completely. - Frontend: the block, its state, the deprecated_settings field of the settings API type and its locale keys are removed, together with the alphaSearchMode* and promptCacheKeyMode* account locale keys that no component used and a stylesheet comment that named the block. - Backend: GET /admin/settings answers upstream's plain document again (no deprecated_settings); saving no longer carries the two values; the setting keys, their defaults, parse and update code, the audit comparison and the two never-read fields of OpenAIRefusalRecoveryRuntime are deleted. - Migration 266 deletes the two settings rows; an integration test restores them next to data that must stay and applies the migration twice. - Tests: the cases that only covered the two values are deleted. The ordinary API-key Alpha Search tests keep every assertion and lose the retired switch from their fixtures, so two are renamed after what they assert: TestForwardAlphaSearchOrdinaryAPIKeyUsesDirectAlphaSearchEndpoint and TestAlphaSearchOrdinaryAPIKeyToolErrorDoesNotSwitchAccounts. The handler fixture passes no settings service again, as before the switch existed. The account option openai_prompt_cache_key_mode and the PAT-only Alpha Search bridge are live and not touched. Co-Authored-By: Claude Opus 5.5 --- .../internal/handler/admin/setting_handler.go | 9 +- .../handler/admin/setting_handler_audit.go | 6 -- .../setting_handler_partial_payload_test.go | 15 ---- .../handler/admin/setting_handler_update.go | 6 +- ...enai_replayable_endpoints_failover_test.go | 46 +--------- ...ttings_purge_migration_integration_test.go | 89 +++++++++++++++++++ backend/internal/server/api_contract_test.go | 2 - backend/internal/service/domain_constants.go | 52 ++++++----- .../service/openai_alpha_search_test.go | 30 ++----- .../service/setting_gateway_runtime.go | 20 ++--- .../setting_gateway_runtime_refusal_test.go | 22 ++--- backend/internal/service/setting_parse.go | 20 ++--- backend/internal/service/setting_update.go | 14 ++- backend/internal/service/settings_view.go | 40 ++++----- ..._retired_openai_apikey_compat_settings.sql | 28 ++++++ frontend/src/api/admin/settings.ts | 3 - .../src/i18n/locales/en/admin/accounts.ts | 9 -- .../src/i18n/locales/en/admin/settings.ts | 10 --- .../src/i18n/locales/zh/admin/accounts.ts | 9 -- .../src/i18n/locales/zh/admin/settings.ts | 10 --- .../console/pages/settings-ops-public.css | 5 +- frontend/src/views/admin/SettingsView.vue | 46 ---------- 22 files changed, 197 insertions(+), 294 deletions(-) create mode 100644 backend/internal/repository/openai_apikey_compat_settings_purge_migration_integration_test.go create mode 100644 backend/migrations/266_purge_retired_openai_apikey_compat_settings.sql diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index be34206f058e..77cf4db7dc2a 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -513,14 +513,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { payload.DefaultPlatformQuotas = platformQuotas } - data := systemSettingsResponseData(payload, authSourceDefaults) - // Deprecated compatibility values are preserved on save but have no public - // write field or runtime effect; they are echoed read-only. - data["deprecated_settings"] = map[string]any{ - service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: settings.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled, - service.SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled: settings.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled, - } - response.Success(c, data) + response.Success(c, systemSettingsResponseData(payload, authSourceDefaults)) } // openaiFastPolicySettingsToDTO converts service -> dto for OpenAI fast policy. diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go index a3957afbcd8c..9218f5d625a9 100644 --- a/backend/internal/handler/admin/setting_handler_audit.go +++ b/backend/internal/handler/admin/setting_handler_audit.go @@ -619,12 +619,6 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.OpenAICyberFailoverEnabled != after.OpenAICyberFailoverEnabled { changed = append(changed, "openai_cyber_failover_enabled") } - if before.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled != after.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled { - changed = append(changed, service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled) - } - if before.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled != after.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled { - changed = append(changed, service.SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled) - } if before.OpenAIRefusalRewriteEnabled != after.OpenAIRefusalRewriteEnabled { changed = append(changed, "openai_refusal_rewrite_enabled") } diff --git a/backend/internal/handler/admin/setting_handler_partial_payload_test.go b/backend/internal/handler/admin/setting_handler_partial_payload_test.go index 1f41a747ffb5..1389b593eb39 100644 --- a/backend/internal/handler/admin/setting_handler_partial_payload_test.go +++ b/backend/internal/handler/admin/setting_handler_partial_payload_test.go @@ -41,21 +41,6 @@ func TestUpdateSettingsPartialPayloadKeepsUnsentKeys(t *testing.T) { require.Equal(t, "true", repo.values[service.SettingKeyTurnstileEnabled]) } -func TestUpdateSettingsIgnoresDeprecatedAPIKeyCompatibilityWrites(t *testing.T) { - h, repo := newStepUpSwitchTestHandler(t, map[string]string{ - service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: "true", - service.SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled: "false", - }) - - rec := doUpdateSettings(t, h, map[string]any{ - "openai_apikey_alpha_search_responses_bridge_enabled": false, - "openai_apikey_prompt_cache_key_normalization_enabled": true, - }, nil) - require.Equal(t, http.StatusOK, rec.Code) - require.Equal(t, "true", repo.values[service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled]) - require.Equal(t, "false", repo.values[service.SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled]) -} - // A full payload keeps whole-document semantics: fields explicitly set to their // zero value are still cleared. func TestUpdateSettingsFullPayloadStillClearsSentEmptyFields(t *testing.T) { diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go index a46fddb605ce..6b50609a406d 100644 --- a/backend/internal/handler/admin/setting_handler_update.go +++ b/backend/internal/handler/admin/setting_handler_update.go @@ -2047,11 +2047,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { }(), OpenAIRefusalRecoveryEnabled: boolValueOrDefault(req.OpenAIRefusalRecoveryEnabled, previousSettings.OpenAIRefusalRecoveryEnabled), OpenAICyberFailoverEnabled: boolValueOrDefault(req.OpenAICyberFailoverEnabled, previousSettings.OpenAICyberFailoverEnabled), - // Deprecated compatibility values are deliberately preserved for a - // rollback window but are no longer public write fields or runtime gates. - OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: previousSettings.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled, - OpenAIAPIKeyPromptCacheKeyNormalizationEnabled: previousSettings.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled, - OpenAIRefusalRewriteEnabled: boolValueOrDefault(req.OpenAIRefusalRewriteEnabled, previousSettings.OpenAIRefusalRewriteEnabled), + OpenAIRefusalRewriteEnabled: boolValueOrDefault(req.OpenAIRefusalRewriteEnabled, previousSettings.OpenAIRefusalRewriteEnabled), OpenAIRefusalKeywords: func() []string { if req.OpenAIRefusalKeywords != nil { return append([]string(nil), (*req.OpenAIRefusalKeywords)...) diff --git a/backend/internal/handler/openai_replayable_endpoints_failover_test.go b/backend/internal/handler/openai_replayable_endpoints_failover_test.go index 9ba5b58f4b89..122721696095 100644 --- a/backend/internal/handler/openai_replayable_endpoints_failover_test.go +++ b/backend/internal/handler/openai_replayable_endpoints_failover_test.go @@ -4,7 +4,6 @@ package handler import ( "bytes" - "context" "errors" "io" "net/http" @@ -93,44 +92,6 @@ func (u *replayableEndpointFailoverUpstream) requestPaths() []string { return append([]string(nil), u.paths...) } -type replayableEndpointSettingRepo struct { - values map[string]string -} - -func (r replayableEndpointSettingRepo) Get(_ context.Context, key string) (*service.Setting, error) { - return nil, nil -} - -func (r replayableEndpointSettingRepo) GetValue(_ context.Context, key string) (string, error) { - return r.values[key], nil -} - -func (r replayableEndpointSettingRepo) Set(_ context.Context, key, value string) error { - return nil -} - -func (r replayableEndpointSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) { - values := make(map[string]string, len(keys)) - for _, key := range keys { - if value, ok := r.values[key]; ok { - values[key] = value - } - } - return values, nil -} - -func (r replayableEndpointSettingRepo) SetMultiple(_ context.Context, settings map[string]string) error { - return nil -} - -func (r replayableEndpointSettingRepo) GetAll(_ context.Context) (map[string]string, error) { - return r.GetMultiple(context.Background(), []string{service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled}) -} - -func (r replayableEndpointSettingRepo) Delete(_ context.Context, key string) error { - return nil -} - func newReplayableEndpointFailoverHandler(t *testing.T, upstream service.HTTPUpstream) (*OpenAIGatewayHandler, int64) { t.Helper() groupID := int64(44001) @@ -165,14 +126,11 @@ func newReplayableEndpointFailoverHandler(t *testing.T, upstream service.HTTPUps }, } cfg := &config.Config{RunMode: config.RunModeSimple} - settingService := service.NewSettingService(replayableEndpointSettingRepo{values: map[string]string{ - service.SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: "true", - }}, cfg) gatewayService := service.NewOpenAIGatewayService( openAIImagesFailoverAccountRepo{accounts: accounts}, nil, nil, nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil, upstream, - nil, nil, nil, nil, nil, nil, settingService, nil, + nil, nil, nil, nil, nil, nil, nil, nil, ) billingService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil) t.Cleanup(billingService.Stop) @@ -217,7 +175,7 @@ func TestAlphaSearchRetriesPoolFailureOnExactSameAccount(t *testing.T) { require.Equal(t, []int64{1, 1}, upstream.calls()) } -func TestAlphaSearchLegacyBridgeSettingsDoNotSwitchOrdinaryAPIKeys(t *testing.T) { +func TestAlphaSearchOrdinaryAPIKeyToolErrorDoesNotSwitchAccounts(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &replayableEndpointFailoverUpstream{ firstStatus: http.StatusBadRequest, diff --git a/backend/internal/repository/openai_apikey_compat_settings_purge_migration_integration_test.go b/backend/internal/repository/openai_apikey_compat_settings_purge_migration_integration_test.go new file mode 100644 index 000000000000..0256f561d0e6 --- /dev/null +++ b/backend/internal/repository/openai_apikey_compat_settings_purge_migration_integration_test.go @@ -0,0 +1,89 @@ +//go:build integration + +package repository + +import ( + "context" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const openAIAPIKeyCompatSettingsPurgeMigration = "266_purge_retired_openai_apikey_compat_settings.sql" + +// TestMain applied every migration, 266 included, to an empty database. The +// test restores the two rows the previous release stored next to settings and +// an account option that must survive, applies 266, checks what was deleted +// and what stayed, and applies it again. +func TestMigration266PurgesRetiredOpenAIAPIKeyCompatSettings(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + exec := func(query string, args ...any) { + t.Helper() + _, err := tx.ExecContext(ctx, query, args...) + require.NoError(t, err) + } + text := func(query string, args ...any) string { + t.Helper() + var value string + require.NoError(t, tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + count := func(query string, args ...any) int { + t.Helper() + var value int + require.NoError(t, tx.QueryRowContext(ctx, query, args...).Scan(&value)) + return value + } + content, err := dbmigrations.FS.ReadFile(openAIAPIKeyCompatSettingsPurgeMigration) + require.NoError(t, err) + migration := string(content) + + const retired = `key IN ('openai_apikey_alpha_search_responses_bridge_enabled', 'openai_apikey_prompt_cache_key_normalization_enabled')` + exec(`INSERT INTO settings (key, value) VALUES + ('openai_apikey_alpha_search_responses_bridge_enabled', 'true'), + ('openai_apikey_prompt_cache_key_normalization_enabled', 'false'), + ('openai_refusal_recovery_enabled', 'true'), + ('openai_cyber_failover_enabled', 'false'), + ('openai_apikey_alpha_search_responses_bridge_enabled_note', 'kept: not a retired key') + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`) + require.Equal(t, 2, count(`SELECT count(*) FROM settings WHERE `+retired)) + + var accountID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO accounts (name, platform, type, extra) +VALUES ('migration-266-prompt-cache-key-mode', 'openai', 'apikey', + '{"openai_long_context_billing_enabled": false, "openai_prompt_cache_key_mode": "sha256_64"}'::jsonb) +RETURNING id`).Scan(&accountID)) + + kept := map[string]string{ + "openai_refusal_recovery_enabled": "true", + "openai_cyber_failover_enabled": "false", + "openai_apikey_alpha_search_responses_bridge_enabled_note": "kept: not a retired key", + } + settingRow := func(key string) string { + t.Helper() + return text(`SELECT ctid::text || '/' || value FROM settings WHERE key = $1`, key) + } + untouched := map[string]string{} + for key := range kept { + untouched[key] = settingRow(key) + } + accountCTID := text(`SELECT ctid::text FROM accounts WHERE id = $1`, accountID) + others := count(`SELECT count(*) FROM settings WHERE NOT (` + retired + `)`) + + for run := 1; run <= 2; run++ { + exec(migration) + + require.Zero(t, count(`SELECT count(*) FROM settings WHERE `+retired), "run %d", run) + require.Equal(t, others, count(`SELECT count(*) FROM settings`), "run %d deletes no other setting", run) + for key, value := range kept { + require.Equal(t, value, text(`SELECT value FROM settings WHERE key = $1`, key), key) + require.Equal(t, untouched[key], settingRow(key), "run %d rewrites no kept setting", run) + } + require.Equal(t, accountCTID, text(`SELECT ctid::text FROM accounts WHERE id = $1`, accountID), "run %d writes no account", run) + require.JSONEq(t, `{"openai_long_context_billing_enabled": false, "openai_prompt_cache_key_mode": "sha256_64"}`, + text(`SELECT extra::text FROM accounts WHERE id = $1`, accountID)) + } +} diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 5205a95fe6a6..bb8ec546a842 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1032,7 +1032,6 @@ func TestAPIContracts(t *testing.T) { "wechat_connect_redirect_url": "", "wechat_connect_frontend_redirect_url": "/auth/wechat/callback", "wechat_connect_scopes": "snsapi_login", - "deprecated_settings": {"openai_apikey_alpha_search_responses_bridge_enabled": false, "openai_apikey_prompt_cache_key_normalization_enabled": false}, "allow_user_view_error_requests": false } }`, @@ -1396,7 +1395,6 @@ func TestAPIContracts(t *testing.T) { "auth_source_default_dingtalk_grant_on_signup": false, "auth_source_default_dingtalk_grant_on_first_bind": false, "force_email_on_third_party_signup": false, - "deprecated_settings": {"openai_apikey_alpha_search_responses_bridge_enabled": false, "openai_apikey_prompt_cache_key_normalization_enabled": false}, "allow_user_view_error_requests": false } }`, diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 4b25872c1af2..4ff97cfd4dec 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -228,33 +228,31 @@ const ( SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组) // 白名单非空时,是否放行非白名单域名按主域名限量注册(每域名 1 个账户)。 // 默认 false:非白名单域名直接拒绝(白名单严格模式)。 - SettingKeyRegistrationEmailDomainQuotaEnabled = "registration_email_domain_quota_enabled" - SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 - SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) - SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接 - SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 - SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关 - SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100) - SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结) - SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久) - SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限) - SettingKeyAffiliateAdminRechargeEnabled = "affiliate_admin_recharge_enabled" // 管理员充值是否产生返利 - SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路 - SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON) - SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关) - SettingKeyCyberPolicyUserAllowlist = "cyber_policy_user_allowlist" // Platform user IDs with log-only cyber handling - SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600) - SettingKeyOpenAIRefusalRecoveryEnabled = "openai_refusal_recovery_enabled" - SettingKeyOpenAICyberFailoverEnabled = "openai_cyber_failover_enabled" - SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled = "openai_apikey_alpha_search_responses_bridge_enabled" - SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled = "openai_apikey_prompt_cache_key_normalization_enabled" - SettingKeyOpenAIRefusalRewriteEnabled = "openai_refusal_rewrite_enabled" - SettingKeyOpenAIRefusalKeywords = "openai_refusal_keywords" - SettingKeyOpenAIRefusalReplacement = "openai_refusal_replacement" - SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款 - SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox - SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用) - SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容) + SettingKeyRegistrationEmailDomainQuotaEnabled = "registration_email_domain_quota_enabled" + SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 + SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) + SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接 + SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 + SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关 + SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100) + SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结) + SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久) + SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限) + SettingKeyAffiliateAdminRechargeEnabled = "affiliate_admin_recharge_enabled" // 管理员充值是否产生返利 + SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路 + SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON) + SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关) + SettingKeyCyberPolicyUserAllowlist = "cyber_policy_user_allowlist" // Platform user IDs with log-only cyber handling + SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600) + SettingKeyOpenAIRefusalRecoveryEnabled = "openai_refusal_recovery_enabled" + SettingKeyOpenAICyberFailoverEnabled = "openai_cyber_failover_enabled" + SettingKeyOpenAIRefusalRewriteEnabled = "openai_refusal_rewrite_enabled" + SettingKeyOpenAIRefusalKeywords = "openai_refusal_keywords" + SettingKeyOpenAIRefusalReplacement = "openai_refusal_replacement" + SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款 + SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox + SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用) + SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容) // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 SettingKeySMTPPort = "smtp_port" // SMTP端口 diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index cb5ed55e9da0..b677fe665d86 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -193,7 +193,7 @@ func TestForwardAlphaSearchPATUsesResponsesWebSearchFallback(t *testing.T) { require.Contains(t, gjson.GetBytes(wireBody, "input.0.content.0.text").String(), `"search_query"`) } -func TestForwardAlphaSearchOrdinaryAPIKeyIgnoresLegacyBridgeSettings(t *testing.T) { +func TestForwardAlphaSearchOrdinaryAPIKeyUsesDirectAlphaSearchEndpoint(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"id":"search-session","model":"gpt-5.6-luna","commands":{"search_query":[{"q":"news"}]}}`) recorder := httptest.NewRecorder() @@ -206,12 +206,7 @@ func TestForwardAlphaSearchOrdinaryAPIKeyIgnoresLegacyBridgeSettings(t *testing. Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(alphaSearchResponsesSSE("search result"))), }} - settings := &SettingService{} - settings.openAIRefusalRecoveryCache.Store(&cachedOpenAIRefusalRecoveryRuntime{ - runtime: OpenAIRefusalRecoveryRuntime{APIKeyAlphaSearchResponsesBridge: true}, - expiresAt: time.Now().Add(time.Minute).UnixNano(), - }) - service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream, settingService: settings} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} account := &Account{ ID: 47, Platform: PlatformOpenAI, @@ -243,12 +238,7 @@ func TestForwardAlphaSearchOrdinaryAPIKeyToolErrorUsesOfficialFailurePath(t *tes Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(bytes.NewReader(upstreamBody)), }} - settings := &SettingService{} - settings.openAIRefusalRecoveryCache.Store(&cachedOpenAIRefusalRecoveryRuntime{ - runtime: OpenAIRefusalRecoveryRuntime{APIKeyAlphaSearchResponsesBridge: true}, - expiresAt: time.Now().Add(time.Minute).UnixNano(), - }) - service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream, settingService: settings} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} account := &Account{ ID: 49, Platform: PlatformOpenAI, @@ -279,12 +269,7 @@ func TestForwardAlphaSearchOrdinaryAPIKeyGenericBadRequestDoesNotFanOut(t *testi Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(upstreamBody)), }} - settings := &SettingService{} - settings.openAIRefusalRecoveryCache.Store(&cachedOpenAIRefusalRecoveryRuntime{ - runtime: OpenAIRefusalRecoveryRuntime{APIKeyAlphaSearchResponsesBridge: true}, - expiresAt: time.Now().Add(time.Minute).UnixNano(), - }) - service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream, settingService: settings} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} account := &Account{ ID: 50, Platform: PlatformOpenAI, @@ -317,12 +302,7 @@ func TestForwardAlphaSearchOrdinaryAPIKeyTrustsOfficialAlphaSearchSuccess(t *tes Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(textOnlySSE)), }} - settings := &SettingService{} - settings.openAIRefusalRecoveryCache.Store(&cachedOpenAIRefusalRecoveryRuntime{ - runtime: OpenAIRefusalRecoveryRuntime{APIKeyAlphaSearchResponsesBridge: true}, - expiresAt: time.Now().Add(time.Minute).UnixNano(), - }) - service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream, settingService: settings} + service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} account := &Account{ ID: 51, Platform: PlatformOpenAI, diff --git a/backend/internal/service/setting_gateway_runtime.go b/backend/internal/service/setting_gateway_runtime.go index 149f3ccc00c3..dc8fb959d28f 100644 --- a/backend/internal/service/setting_gateway_runtime.go +++ b/backend/internal/service/setting_gateway_runtime.go @@ -172,12 +172,10 @@ const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings" type OpenAIRefusalRecoveryRuntime struct { - Enabled bool - CyberFailover bool - APIKeyAlphaSearchResponsesBridge bool - APIKeyPromptCacheKeyNormalization bool - Rewrite bool - Matcher *OpenAIRefusalMatcher + Enabled bool + CyberFailover bool + Rewrite bool + Matcher *OpenAIRefusalMatcher } type cachedOpenAIRefusalRecoveryRuntime struct { @@ -248,8 +246,6 @@ func (s *SettingService) GetOpenAIRefusalRecoveryRuntime(ctx context.Context) Op values, err := s.settingRepo.GetMultiple(dbCtx, []string{ SettingKeyOpenAIRefusalRecoveryEnabled, SettingKeyOpenAICyberFailoverEnabled, - SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled, - SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled, SettingKeyOpenAIRefusalRewriteEnabled, SettingKeyOpenAIRefusalKeywords, SettingKeyOpenAIRefusalReplacement, @@ -276,11 +272,9 @@ func (s *SettingService) GetOpenAIRefusalRecoveryRuntime(ctx context.Context) Op func buildOpenAIRefusalRecoveryRuntime(values map[string]string) OpenAIRefusalRecoveryRuntime { runtime := OpenAIRefusalRecoveryRuntime{ - Enabled: values[SettingKeyOpenAIRefusalRecoveryEnabled] == "true", - CyberFailover: values[SettingKeyOpenAICyberFailoverEnabled] == "true", - APIKeyAlphaSearchResponsesBridge: values[SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled] == "true", - APIKeyPromptCacheKeyNormalization: values[SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled] == "true", - Rewrite: values[SettingKeyOpenAIRefusalRewriteEnabled] == "true", + Enabled: values[SettingKeyOpenAIRefusalRecoveryEnabled] == "true", + CyberFailover: values[SettingKeyOpenAICyberFailoverEnabled] == "true", + Rewrite: values[SettingKeyOpenAIRefusalRewriteEnabled] == "true", } if !runtime.Enabled || !runtime.Rewrite { return runtime diff --git a/backend/internal/service/setting_gateway_runtime_refusal_test.go b/backend/internal/service/setting_gateway_runtime_refusal_test.go index ffecfdca4679..3a085875d4d9 100644 --- a/backend/internal/service/setting_gateway_runtime_refusal_test.go +++ b/backend/internal/service/setting_gateway_runtime_refusal_test.go @@ -147,13 +147,11 @@ func (r *openAIRefusalRuntimeRepo) calls() (int, int) { func openAIRefusalRuntimeValues(rewrite bool) map[string]string { values := map[string]string{ - SettingKeyOpenAIRefusalRecoveryEnabled: "true", - SettingKeyOpenAICyberFailoverEnabled: "true", - SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: "true", - SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled: "true", - SettingKeyOpenAIRefusalRewriteEnabled: "false", - SettingKeyOpenAIRefusalKeywords: `["cannot"]`, - SettingKeyOpenAIRefusalReplacement: "continue current task", + SettingKeyOpenAIRefusalRecoveryEnabled: "true", + SettingKeyOpenAICyberFailoverEnabled: "true", + SettingKeyOpenAIRefusalRewriteEnabled: "false", + SettingKeyOpenAIRefusalKeywords: `["cannot"]`, + SettingKeyOpenAIRefusalReplacement: "continue current task", } if rewrite { values[SettingKeyOpenAIRefusalRewriteEnabled] = "true" @@ -161,16 +159,6 @@ func openAIRefusalRuntimeValues(rewrite bool) map[string]string { return values } -func TestOpenAIRefusalRuntimeLoadsAPIKeyCompatibilitySwitches(t *testing.T) { - repo := &openAIRefusalRuntimeRepo{values: openAIRefusalRuntimeValues(false)} - svc := &SettingService{settingRepo: repo} - - runtime := svc.GetOpenAIRefusalRecoveryRuntime(context.Background()) - - require.True(t, runtime.APIKeyAlphaSearchResponsesBridge) - require.True(t, runtime.APIKeyPromptCacheKeyNormalization) -} - func expireOpenAIRefusalRuntimeCache(t *testing.T, svc *SettingService) { t.Helper() entry, ok := svc.openAIRefusalRecoveryCache.Load().(*cachedOpenAIRefusalRecoveryRuntime) diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 66db3492dcdf..dfeb65bd68da 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -219,16 +219,14 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyRiskControlEnabled: "false", // cyber 会话屏蔽(默认关闭,TTL 默认 3600s) - SettingKeyCyberSessionBlockEnabled: "false", - SettingKeyCyberPolicyUserAllowlist: "", - SettingKeyCyberSessionBlockTTLSeconds: "3600", - SettingKeyOpenAIRefusalRecoveryEnabled: "false", - SettingKeyOpenAICyberFailoverEnabled: "false", - SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: "false", - SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled: "false", - SettingKeyOpenAIRefusalRewriteEnabled: "false", - SettingKeyOpenAIRefusalKeywords: defaultOpenAIRefusalKeywordsJSON, - SettingKeyOpenAIRefusalReplacement: "", + SettingKeyCyberSessionBlockEnabled: "false", + SettingKeyCyberPolicyUserAllowlist: "", + SettingKeyCyberSessionBlockTTLSeconds: "3600", + SettingKeyOpenAIRefusalRecoveryEnabled: "false", + SettingKeyOpenAICyberFailoverEnabled: "false", + SettingKeyOpenAIRefusalRewriteEnabled: "false", + SettingKeyOpenAIRefusalKeywords: defaultOpenAIRefusalKeywordsJSON, + SettingKeyOpenAIRefusalReplacement: "", // Claude Code version check (default: empty = disabled) SettingKeyMinClaudeCodeVersion: "", SettingKeyMaxClaudeCodeVersion: "", @@ -858,8 +856,6 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.OpenAIRefusalRecoveryEnabled = settings[SettingKeyOpenAIRefusalRecoveryEnabled] == "true" result.OpenAICyberFailoverEnabled = settings[SettingKeyOpenAICyberFailoverEnabled] == "true" - result.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled = settings[SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled] == "true" - result.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled = settings[SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled] == "true" result.OpenAIRefusalRewriteEnabled = settings[SettingKeyOpenAIRefusalRewriteEnabled] == "true" result.OpenAIRefusalKeywords = parseOpenAIRefusalKeywordsSetting(settings[SettingKeyOpenAIRefusalKeywords]) result.OpenAIRefusalReplacement = settings[SettingKeyOpenAIRefusalReplacement] diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go index dd888ed1a53e..7efbc72291af 100644 --- a/backend/internal/service/setting_update.go +++ b/backend/internal/service/setting_update.go @@ -466,8 +466,6 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting } updates[SettingKeyOpenAIRefusalRecoveryEnabled] = strconv.FormatBool(settings.OpenAIRefusalRecoveryEnabled) updates[SettingKeyOpenAICyberFailoverEnabled] = strconv.FormatBool(settings.OpenAICyberFailoverEnabled) - updates[SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled] = strconv.FormatBool(settings.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled) - updates[SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled] = strconv.FormatBool(settings.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled) updates[SettingKeyOpenAIRefusalRewriteEnabled] = strconv.FormatBool(settings.OpenAIRefusalRewriteEnabled) refusalKeywordsJSON, err := json.Marshal(settings.OpenAIRefusalKeywords) if err != nil { @@ -729,13 +727,11 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) { s.openAIRefusalRecoverySF.Forget("openai_refusal_recovery") s.openAIRefusalRecoveryCache.Store(&cachedOpenAIRefusalRecoveryRuntime{ runtime: buildOpenAIRefusalRecoveryRuntime(map[string]string{ - SettingKeyOpenAIRefusalRecoveryEnabled: strconv.FormatBool(settings.OpenAIRefusalRecoveryEnabled), - SettingKeyOpenAICyberFailoverEnabled: strconv.FormatBool(settings.OpenAICyberFailoverEnabled), - SettingKeyOpenAIAPIKeyAlphaSearchResponsesBridgeEnabled: strconv.FormatBool(settings.OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled), - SettingKeyOpenAIAPIKeyPromptCacheKeyNormalizationEnabled: strconv.FormatBool(settings.OpenAIAPIKeyPromptCacheKeyNormalizationEnabled), - SettingKeyOpenAIRefusalRewriteEnabled: strconv.FormatBool(settings.OpenAIRefusalRewriteEnabled), - SettingKeyOpenAIRefusalKeywords: string(keywordsJSON), - SettingKeyOpenAIRefusalReplacement: settings.OpenAIRefusalReplacement, + SettingKeyOpenAIRefusalRecoveryEnabled: strconv.FormatBool(settings.OpenAIRefusalRecoveryEnabled), + SettingKeyOpenAICyberFailoverEnabled: strconv.FormatBool(settings.OpenAICyberFailoverEnabled), + SettingKeyOpenAIRefusalRewriteEnabled: strconv.FormatBool(settings.OpenAIRefusalRewriteEnabled), + SettingKeyOpenAIRefusalKeywords: string(keywordsJSON), + SettingKeyOpenAIRefusalReplacement: settings.OpenAIRefusalReplacement, }), expiresAt: time.Now().Add(openAIRefusalRecoveryCacheTTL).UnixNano(), }) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index fd7846b428c7..81ee43d72452 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -165,27 +165,25 @@ type SystemSettings struct { CustomMenuItems string // JSON array of custom menu items CustomEndpoints string // JSON array of custom endpoints - DefaultConcurrency int - DefaultBalance float64 - RiskControlEnabled bool - CyberSessionBlockEnabled bool - CyberPolicyUserAllowlist string - CyberSessionBlockTTLSeconds int - OpenAIRefusalRecoveryEnabled bool - OpenAICyberFailoverEnabled bool - OpenAIAPIKeyAlphaSearchResponsesBridgeEnabled bool - OpenAIAPIKeyPromptCacheKeyNormalizationEnabled bool - OpenAIRefusalRewriteEnabled bool - OpenAIRefusalKeywords []string - OpenAIRefusalReplacement string - AffiliateEnabled bool - AffiliateRebateRate float64 - AffiliateRebateFreezeHours int - AffiliateRebateDurationDays int - AffiliateRebatePerInviteeCap float64 - AdminRechargeRebateEnabled bool - DefaultUserRPMLimit int - DefaultSubscriptions []DefaultSubscriptionSetting + DefaultConcurrency int + DefaultBalance float64 + RiskControlEnabled bool + CyberSessionBlockEnabled bool + CyberPolicyUserAllowlist string + CyberSessionBlockTTLSeconds int + OpenAIRefusalRecoveryEnabled bool + OpenAICyberFailoverEnabled bool + OpenAIRefusalRewriteEnabled bool + OpenAIRefusalKeywords []string + OpenAIRefusalReplacement string + AffiliateEnabled bool + AffiliateRebateRate float64 + AffiliateRebateFreezeHours int + AffiliateRebateDurationDays int + AffiliateRebatePerInviteeCap float64 + AdminRechargeRebateEnabled bool + DefaultUserRPMLimit int + DefaultSubscriptions []DefaultSubscriptionSetting // Model fallback configuration EnableModelFallback bool `json:"enable_model_fallback"` FallbackModelAnthropic string `json:"fallback_model_anthropic"` diff --git a/backend/migrations/266_purge_retired_openai_apikey_compat_settings.sql b/backend/migrations/266_purge_retired_openai_apikey_compat_settings.sql new file mode 100644 index 000000000000..185f9221a9fd --- /dev/null +++ b/backend/migrations/266_purge_retired_openai_apikey_compat_settings.sql @@ -0,0 +1,28 @@ +-- Delete the two retired OpenAI API-key compatibility settings. +-- +-- openai_apikey_alpha_search_responses_bridge_enabled and +-- openai_apikey_prompt_cache_key_normalization_enabled were the global switches +-- of the API-key Alpha Search bridge and of prompt_cache_key normalization. +-- Their gates and write fields were removed on 2026-09-01 (git cce166e21). The +-- previous release still loaded both rows, showed them read-only on the +-- settings page and wrote the stored value back on every save (git 06e865f6e, +-- backend/internal: service/domain_constants.go:247-248, +-- service/setting_update.go:469-470, service/setting_gateway_runtime.go:251-252, +-- handler/admin/setting_handler.go:489-495). This release removes that code, so +-- nothing reads or writes the rows any more. +-- +-- Only these two settings rows are deleted. The account option +-- openai_prompt_cache_key_mode in accounts.extra is live +-- (service/openai_apikey_compat.go) and is not touched; neither is any other +-- setting, nor the audit log. +-- +-- Deleting an absent row is not an error, so the migration can run again. +-- No explicit BEGIN/COMMIT: the runner owns this file's transaction. +SET LOCAL lock_timeout = '10s'; +SET LOCAL statement_timeout = '120s'; + +DELETE FROM settings +WHERE key IN ( + 'openai_apikey_alpha_search_responses_bridge_enabled', + 'openai_apikey_prompt_cache_key_normalization_enabled' +); diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 4bb4dcf70b56..63d87b474098 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -793,9 +793,6 @@ export interface SystemSettings { // Allow user view error requests allow_user_view_error_requests: boolean; - - // Retired switches, echoed read-only by GET (never written): setting key -> stored value - deprecated_settings?: Record; } export interface UpdateSettingsRequest { diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index aa3211fec19c..96be1d611486 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -878,15 +878,6 @@ export default {accounts: { responsesModeForceChatCompletions: 'Force Chat Completions', responsesModeTextDisabledHint: 'Not applicable when the Responses / Chat Completions endpoint is not enabled.', - alphaSearchMode: 'Alpha Search mode', - alphaSearchModeDesc: 'OpenAI API keys only. Bridge mode also requires the global admin switch; disabled accounts are excluded from search scheduling.', - alphaSearchModeDirect: 'Direct /v1/alpha/search', - alphaSearchModeResponsesWebSearch: 'Responses Web Search bridge', - alphaSearchModeDisabled: 'Disable search', - promptCacheKeyMode: 'Prompt cache key mode', - promptCacheKeyModeDesc: 'OpenAI API keys only. SHA-256 mode also requires the global admin switch and only rewrites values longer than 64 characters.', - promptCacheKeyModePassthrough: 'Pass through', - promptCacheKeyModeSHA25664: 'SHA-256 (64 characters)', imagesUrlToB64Json: 'Image result URL to base64', imagesUrlToB64JsonDesc: 'Only applies to non-streaming Images responses of OpenAI API Key accounts. When an upstream image item has a url but no b64_json, the gateway downloads the url and fills b64_json with its base64 content (url is kept) for clients built on the official API; the response is returned unchanged if the download fails.', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 365063a0bb77..b87c983157fe 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -558,16 +558,6 @@ export default { reference: 'Reference catalog', sources: 'Sources' }, - deprecatedSettings: { - title: 'Deprecated settings (read-only, no effect)', - hint: 'These switches are retired: saving keeps the stored values, but nothing reads them at runtime. Shown here as stored.', - on: 'On', - off: 'Off', - labels: { - openai_apikey_alpha_search_responses_bridge_enabled: 'OpenAI API-key accounts: Alpha Search via the Responses bridge', - openai_apikey_prompt_cache_key_normalization_enabled: 'OpenAI API-key accounts: prompt_cache_key normalization' - } - }, gatewayForwarding: { title: 'Request Forwarding', description: 'Control how requests are forwarded to upstream OAuth accounts', diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index 8e9482c2c890..2cb6f3eb4aa5 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -1018,15 +1018,6 @@ export default {accounts: { responsesModeForceResponses: '强制 Responses', responsesModeForceChatCompletions: '强制 Chat Completions', responsesModeTextDisabledHint: '未启用 Responses / Chat Completions 端点时,此设置不适用。', - alphaSearchMode: 'Alpha Search 模式', - alphaSearchModeDesc: '仅对 OpenAI API Key 生效。桥接模式还要求管理端全局开关已开启;禁用模式会从搜索调度中排除本账号。', - alphaSearchModeDirect: '直接调用 /v1/alpha/search', - alphaSearchModeResponsesWebSearch: 'Responses Web Search 桥接', - alphaSearchModeDisabled: '禁用搜索', - promptCacheKeyMode: 'Prompt Cache Key 模式', - promptCacheKeyModeDesc: '仅对 OpenAI API Key 生效。SHA-256 模式还要求管理端全局开关已开启,且只改写超过 64 字符的值。', - promptCacheKeyModePassthrough: '原样透传', - promptCacheKeyModeSHA25664: 'SHA-256(64 位)', imagesUrlToB64Json: '生图结果 URL 转 base64', imagesUrlToB64JsonDesc: '仅对 OpenAI API Key 的 Images 非流式响应生效。上游返回的图片缺少 b64_json 但带 url 时,网关下载该 url 并以 base64 回填 b64_json(url 保留),兼容按官方接口实现的客户端;下载失败则原样返回。', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index db827fd308b2..fcb8842db0c1 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -551,16 +551,6 @@ export default { reference: '参考目录', sources: '来源' }, - deprecatedSettings: { - title: '已弃用设置(只读,不生效)', - hint: '这些开关已停用:保存设置时保留库里的原值,运行时不再读取。这里只显示当前存储的值。', - on: '开启', - off: '关闭', - labels: { - openai_apikey_alpha_search_responses_bridge_enabled: 'OpenAI API Key 账号:Alpha Search 走 Responses 桥接', - openai_apikey_prompt_cache_key_normalization_enabled: 'OpenAI API Key 账号:prompt_cache_key 归一化' - } - }, gatewayForwarding: { title: '请求转发行为', description: '控制请求转发到上游 OAuth 账号时的行为', diff --git a/frontend/src/styles/console/pages/settings-ops-public.css b/frontend/src/styles/console/pages/settings-ops-public.css index dab13126a5d7..b86b6a3f4307 100644 --- a/frontend/src/styles/console/pages/settings-ops-public.css +++ b/frontend/src/styles/console/pages/settings-ops-public.css @@ -2599,9 +2599,8 @@ html.flat-theme [data-ui~="settings"] .card :where(label[class~="font-medium"][c } /* 3.4 Field labels. The recipe and the block utility labels are the mono label already (controls.css 3.5, style.css - .input-label); the inline ones that sit right before their field (label[for] + input.mt-2, the grok / TTFT / - retired-switch fields, the small grey labels of the search provider and the backup list) join them, so a form has - one label language. */ + .input-label); the inline ones that sit right before their field (label[for] + input.mt-2, the grok / TTFT fields, + the small grey labels of the search provider and the backup list) join them, so a form has one label language. */ html.flat-theme [data-ui~="settings"] label[class~="text-sm"][class~="font-medium"]:not([class~="block"]):has(+ :is(input, select, textarea, div)), html.flat-theme [data-ui~="settings"] label[class~="text-xs"]:is([class~="text-gray-500"], [class~="text-gray-600"]):has(+ :is(input, select, textarea, div)) { display: block; diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 151159d1d48a..dde9abe37d33 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5414,33 +5414,6 @@

- -
-
- {{ t("admin.settings.deprecatedSettings.title") }} -
-

- {{ t("admin.settings.deprecatedSettings.hint") }} -

-
-
-
- {{ row.label }} - {{ row.key }} -
-
{{ row.value }}
-
-
-
-
@@ -9388,24 +9361,6 @@ const { copyToClipboard } = useClipboard(); const loading = ref(true); const loadFailed = ref(false); -// GET /admin/settings echoes the retired switches read-only; they are shown, never saved. -const deprecatedSettings = ref>({}); -const deprecatedSettingRows = computed(() => - Object.entries(deprecatedSettings.value).map(([key, value]) => { - const labelKey = `admin.settings.deprecatedSettings.labels.${key}`; - const label = t(labelKey); - return { - key, - label: label === labelKey ? key : label, - value: - value === true - ? t("admin.settings.deprecatedSettings.on") - : value === false - ? t("admin.settings.deprecatedSettings.off") - : JSON.stringify(value), - }; - }), -); const saving = ref(false); const testingSmtp = ref(false); const sendingTestEmail = ref(false); @@ -11416,7 +11371,6 @@ async function loadSettings() { loadFailed.value = false; try { const settings = await adminAPI.settings.getSettings(); - deprecatedSettings.value = settings.deprecated_settings ?? {}; settings.payment_load_balance_strategy = settings.payment_load_balance_strategy || "round-robin"; // Only assign non-null values from backend (null means unconfigured, keep defaults) From 8df0a1c743f9a00d66911413bf14ced342b7a7de Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 18:47:42 +0800 Subject: [PATCH 06/39] docs(downstream): state what the three follow-ups ship on one tree The v0.2.13 review was last written for the profit control follow-up alone. With the settings purge and the reasoning options change on the same tree, three of its statements were no longer true: the first start applies four migrations, not three (266 is new); two settings rows are deleted on upgrade; and the per-account reasoning options are not untouched. - Integration decisions: the deletion of the two retired API-key compatibility settings, and the frontend half of the reasoning options change, with what this tree still lacks (the endpoint and the removal of the per-account backend code) and the save error that follows for an account whose stored value is not a boolean. - Migrations: 266. - Validation: the checks run on the combined tree, why upstream-risk.json needs no regeneration, and an intro line that fits the follow-up bullets. - Release target: four migrations; no setting takes a new value. Co-Authored-By: Claude Opus 5.5 --- .downstream/upstream-review-v0.2.13.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/.downstream/upstream-review-v0.2.13.md b/.downstream/upstream-review-v0.2.13.md index 27489848f2a4..a22323614d42 100644 --- a/.downstream/upstream-review-v0.2.13.md +++ b/.downstream/upstream-review-v0.2.13.md @@ -6,7 +6,7 @@ - Upstream: stable tag `v0.2.13` (tag object `7d0c0067f406c380f0a94cfc3879cdae7049b467`), commit `3040209f205472038c1ba745a1bedd2edd9053b1`; merged at `upstream/main` `b8dece9000c68815a5b867ca5a1e6f236e173905`, which only adds the VERSION sync (the tagged commit still says `0.2.12`). `v0.2.12` (`5106065716e494204fc0e8db16f68f6e9d576be0`) lies inside the range and is integrated with it. - v0.2.11 was integrated by a squash (#220), so git did not know it was merged. A first `merge -s ours 42bc7f6cffe24bcb471608e48e66b4a0afa1f882` commit records it without changing the tree, which makes that commit (`v0.2.11` plus its VERSION sync) the real shared ancestor. - Upstream delta: 26 commits (39 with merges), 157 paths (28 added, 129 modified), +6027/-314. 64 paths overlap downstream changes (65 in the tag-to-tag risk manifest, which also counts `VERSION`); 16 of them conflicted textually. -- Upstream brings two SQL migrations and two ent schema changes (`payment_orders.bonus_amount`, `typesafe` in the quota platform validator). This integration adds one downstream tail migration (see Migrations). No Go dependency changed. Upstream's only frontend dependency change, axios `^1.20.0`, was already downstream: `frontend/package.json`, `frontend/pnpm-lock.yaml`, `backend/go.mod` and `backend/go.sum` are identical to the downstream base. +- Upstream brings two SQL migrations and two ent schema changes (`payment_orders.bonus_amount`, `typesafe` in the quota platform validator). This integration adds one downstream tail migration, and the release carries a second downstream migration that deletes two retired settings rows (see Migrations). No Go dependency changed. Upstream's only frontend dependency change, axios `^1.20.0`, was already downstream: `frontend/package.json`, `frontend/pnpm-lock.yaml`, `backend/go.mod` and `backend/go.sum` are identical to the downstream base. ## Integration decisions @@ -30,6 +30,11 @@ Every official commit is adopted with its official behaviour. Where upstream and - No migration. The three columns come from 192, and `enqueue_group_auth_cache_invalidation` as last defined by 260 has the body of 193, which compares the three columns. - Console theme: no rule and no hook was added. The section has the markup of the peak-rate block above it, and its divider, checkbox, label, hint, field labels and inputs resolve to the same computed styles as their neighbours in light and dark. - A group whose stored `profit_control_enabled` is true is gated from the first start of this release; the stored values were not read here. +- **The two retired OpenAI API-key compatibility settings are deleted.** `openai_apikey_alpha_search_responses_bridge_enabled` and `openai_apikey_prompt_cache_key_normalization_enabled` lost their gates and write fields on 2026-09-01 (`cce166e21`). `v0.2.11-codexrip.8` only loaded them, showed them in 系统设置 as 已弃用设置(只读,不生效) and wrote them back unchanged on every save. All of it is removed (owner decision of 2026-10-03): the block and its locale keys, the `alphaSearchMode*` and `promptCacheKeyMode*` account locale keys that no component used, the `deprecated_settings` field of `GET /admin/settings` (the handler ends as upstream's again), the two setting keys with their defaults, parse, update and audit code, the two never-read fields of `OpenAIRefusalRecoveryRuntime`, and the stored rows (migration 266, see Migrations). The account option `openai_prompt_cache_key_mode` and the PAT-only Alpha Search bridge are live and not touched. +- **Per-account reasoning options: the frontend half.** The account create, edit and bulk edit dialogs no longer show or send `openai_chat_reasoning_replay_enabled` and `openai_reasoning_signature_recovery_enabled` (owner decision of 2026-10-03); `OpenAIReasoningPolicyFields.vue`, `utils/openaiReasoningPolicy.ts`, their specs and locale keys are deleted. Invalid reasoning ciphertext recovery gets one global switch on the new page 推理恢复 (`/admin/reasoning-recovery`, sidebar section 扩展功能, after Codex 运行设置): the switch with Reload and Save, no step-up, read and written through `GET` and `PUT /api/v1/admin/reasoning-recovery` as `{"enabled": boolean}`. + - The backend half is not in this tree. The endpoint does not exist, so the page shows its load error and no switch, and the gateway still evaluates the two keys per account as before. + - Until it lands, the edit dialog sends the stored `extra` of an OpenAI account as it is, so saving an account whose stored value of one of the two keys is not a boolean is answered with 400 `OPENAI_REASONING_POLICY_INVALID`. + - Console theme: the page joins the selectors of the 系统提示词 rules through its own static `data-ui="reasoning-recovery"` hook (`pages/settings-ops-public.css`, and the narrow-screen heading rule of `pages/user.css`); it has no rule of its own. - **Console theme for the new upstream UI.** Behaviour is upstream's; only styling was added under `frontend/src/styles/console`: the eleventh platform segment of the account dialog takes the `sky` identity glyph (`controls.css` 7.1a); the priority control sits as the value of the stacked priority line with label-toned steppers (`pages/accounts.css` 5, reached through the component's own static `data-testid` attributes); the mode toggle of `RechargeBonusTierEditor` carries the static `data-ui="seg-box"` hook. No `:has()`, no runtime-bound hook. - One existing rule also reaches the new UI without a change: the `RechargeBonusTierEditor` card has the same header and `p-4` body as the payment provider card beside it, so the positional body rule of `pages/settings-ops-public.css` 2.2 gives it the settings card padding. The comment of that rule now names both cards. @@ -42,6 +47,7 @@ Every official commit is adopted with its official behaviour. Where upstream and - **Final constraints without a downstream tail.** Production would end with the eleven platforms in both CHECKs, plus the retired `cindy` value in the quota CHECK. A new database would run upstream's 241 first and then 241 compat, 251 and 260, ending with ten platforms in the quota CHECK and `typesafe` missing, while the composite CHECK keeps eleven on both paths (no downstream file re-creates it after upstream's 241). With `typesafe` missing, a default TypeSafe quota makes the multi-row quota snapshot of a new user violate the CHECK. - **Fix.** `265_user_platform_quota_typesafe_union.sql` states the eleven-platform quota CHECK once more at the tail (264 is `264_purge_codex_runtime_plugin_data.sql` from #233), so every path ends with the same eleven platforms and without `cindy`: `SET LOCAL` lock and statement timeouts, `DROP CONSTRAINT IF EXISTS` plus `ADD CONSTRAINT`, no row written, no explicit transaction control, a no-op when run again. `TestTypeSafeStaysInTheFinalPlatformChecks` pins the last file that defines each constraint (265 for the quota CHECK, upstream's 241 for the composite CHECK), the eleven platforms and the shape of the tail file. - **Standing hazard.** Each future upstream migration that re-creates a platform CHECK with a number below the downstream tail needs another tail migration of this kind, and `cindy` in its quota list for as long as a database older than 260 has to upgrade in one start. +- **Settings purge.** `266_purge_retired_openai_apikey_compat_settings.sql` deletes the two retired settings rows by key and nothing else: `SET LOCAL` lock and statement timeouts, one `DELETE FROM settings`, no explicit transaction control, a no-op when the rows are absent. It adds no constraint, so 265 stays the last file that states the quota CHECK. `TestMigration266PurgesRetiredOpenAIAPIKeyCompatSettings` (tag `integration`) restores the two rows next to settings and an account option that must stay and applies the file twice. By reading, an older image loses nothing with the rows: it reads an absent row as off, and no gate read either value since 2026-09-01. - Rollback boundary, by reading: an older image ignores ledger rows of files it does not embed, the extra column has a default and the wider CHECKs accept every older value. TypeSafe accounts, groups and quota rows created after the upgrade are not understood by an older image. - Generated code: `go generate ./ent` reproduces the merged generated code without a difference. `wire.go` and `wire_gen.go` are unchanged; upstream changed no provider input (the Redis client for the new rate limit is passed inside `router.go`). @@ -62,7 +68,7 @@ Every official commit is adopted with its official behaviour. Where upstream and - token counting selects through the scheduler under the handler's suppress marker, so upstream's `SelectAccountForTokenCount` is not used; - the eligibility helpers return `(bool, reason)` for the downstream selection diagnostics, and the handlers pass the pricing instant through the downstream usage snapshot; - the auth snapshot version is the downstream counter (28; upstream 24). -- Image Studio and the Responses image bridge stay off, the public model page stays retired and the per-account reasoning policy switches are untouched; no upstream change in this range touches them. +- Image Studio and the Responses image bridge stay off and the public model page stays retired; no upstream change in this range touches them. No upstream change touches the per-account reasoning options either; what changes for them is the downstream follow-up described under Integration decisions. ## Conflict resolutions @@ -93,7 +99,7 @@ Every official commit is adopted with its official behaviour. Where upstream and ## Validation -- Every bullet except "After the review fixes" describes the checks of the integration before its review; that bullet lists what was run again on the final tree. +- Every bullet before "After the review fixes" describes the checks of the integration before its review; that bullet lists what was run again on the final tree of the integration, and the two after it cover the follow-ups on top of it. - `go build ./...` passes for windows and for `GOOS=linux GOARCH=amd64`. `go vet ./...` passes plain and with each of the `unit`, `integration` and `e2e` tags, so every merged test file compiles. `gofmt -l` is empty for the Go files that differ from the downstream base. - Focused `go test -tags=unit` runs pass: `migrations` (both TypeSafe migration tests); `service` (TypeSafe connection test, connection test model, catalog auto-refresh eligibility, composite capacity pool, `jev-latest` pricing, `ForwardSystemOne`, Antigravity client error body, TypeSafe account creation with the billing probe, platform lists, Grok headers, recharge bonus quote and rebate base, reset token); `handler` (System One, protocol rejection, model lists); `handler/admin` (available models, group platform binding, pricing sync); `server` (admin settings contract); `server/routes` (public order verify rate limit, prompt audit route coverage, group model allowlist routes); `repository` (API key sort, deleted-key usage settlement, email cache, Grok CLI identity); `model` (platform list). - `golangci-lint` 2.13 reports 0 issues on `ent`, `domain`, `handler`, `model`, `pkg/typesafe`, `pkg/xai`, `repository`, `securityaudit`, `server`, `service` and `migrations`. @@ -105,10 +111,11 @@ Every official commit is adopted with its official behaviour. Where upstream and - After the review fixes (System One Ops body, connection test success rule, `cindy` in the 241 quota list, the CSS comment), on the final tree: `go build ./...` (windows) and `go vet -tags=unit` on `pkg/typesafe`, `service` and `migrations` pass, and `gofmt -l` is empty for the six changed Go files. Focused tests pass: `pkg/typesafe` (`TestEvaluateContract` and both `DecodeSystemOneResponse` tests), `migrations` (both TypeSafe migration tests), `service` (the five TypeSafe connection tests; the `ForwardSystemOne` error policy, request error, account-level failover, oversized response and response-without-model tests; the new Ops record test). `golangci-lint` 2.13 reports 0 issues on `pkg/typesafe`, `migrations` and `service`. Not repeated: the linux build, the `integration` and `e2e` vet runs, the other packages and every frontend check. The fixes changed no platform-specific code, no tagged test file and no other package, and in the frontend only a CSS comment. - After merging `origin/main` (`291d25e7f`, #233): one conflict, `backend/internal/service/admin_account.go`, where upstream's TypeSafe API-key-only check stays and the `maps.Clone(accountExtra)` line that #233 removed stays removed. `.downstream/upstream-risk.json` was regenerated against the new downstream commit (1420 downstream paths; 157 upstream, 65 overlapping and 31 critical as before). On that tree `go build ./...` and `go vet -tags=unit` on `service`, `handler`, `repository`, `server` and `migrations` pass, the two TypeSafe migration tests and the `service` tests matching `TypeSafe` pass, and `test_upstream_risk.py` and `test_upstream_automation_contract.py` pass. No frontend check was repeated locally. - Profit control, on top of the integration: `go build ./...` (windows) passes; `go vet -tags=unit` passes on `service`, `handler`, `handler/admin`, `handler/dto` and `cmd/profit-preview`; `gofmt -l` is empty for the changed Go files; `golangci-lint` 2.13 reports 0 issues on the same five packages. Upstream's profit tests pass unchanged against the downstream scheduler: 45 in `service` (gate resolution, veto reasons, scheduler and legacy filtering, previous-response reuse, pricing context, the shared-gateway gate, auth snapshot roundtrip and eviction, preview), 10 in `handler` (veto budget, failover loop, slot recheck), 2 in `handler/dto`, 3 in `cmd/profit-preview`. Also passing: the two simple-mode group tests, the other auth snapshot tests, the selection failure statistics tests, the group duplicate tests and the group handler tests. `vue-tsc --noEmit` passes; vitest passes for `groupsProfitControl.spec.ts`, the four `GroupsView` specs and locale key completeness; ESLint passes on the changed frontend files. A local preview against a mock API showed the section in the create and edit dialogs in light and dark (off, on with both numbers, the sum error, a subscription group with the peak-rate block, 390 px); the captures are not part of the repository. Not run for this change: the linux build, the `integration` and `e2e` vet runs, and the two database-backed profit tests in `repository`, which are unchanged upstream files. +- The follow-ups on one tree (profit control, the settings purge, the reasoning options; their change sets share no file): `go build ./...` (windows) passes; `go vet` passes plain and with the `unit` tag on `cmd/profit-preview`, `handler`, `handler/admin`, `handler/dto`, `repository`, `server` and `service`, and with the `integration` tag on `repository`; `gofmt -l` is empty for the changed Go files; `vue-tsc --noEmit`, locale key completeness and ESLint on the changed frontend files pass; `test_upstream_risk.py` and `test_upstream_automation_contract.py` pass. `.downstream/upstream-risk.json` needs no regeneration: it is computed from the two upstream tags and the downstream base alone, and recomputing it reproduces every key the gate compares. Not run on the combined tree: Go tests, the other vitest specs, `golangci-lint`, the linux build, and migration 266 against a database. - Not run: full suites, race tests, integration tests against a database, a new-database, older-database or production migration rehearsal, real model or TypeSafe calls, deploy. Source integration does not claim a successful deployment. ## Release target -- `v0.2.13-codexrip.1`, fixed downstream image, ordinary `deploy-preserve` (natural drain, only the Sub2API container recreated, runtime configuration kept). The first start applies three migrations: the two upstream 241 files and 265. No setting changes on upgrade: no recharge tier and no TypeSafe account or group exists until an administrator creates one, and profit control stays off for every group that has not enabled it. +- `v0.2.13-codexrip.1`, fixed downstream image, ordinary `deploy-preserve` (natural drain, only the Sub2API container recreated, runtime configuration kept). The first start applies four migrations: the two upstream 241 files, 265 and 266. No setting takes a new value on upgrade: 266 only deletes the two retired rows, which had no effect; no recharge tier and no TypeSafe account or group exists until an administrator creates one, and profit control stays off for every group that has not enabled it. - Not part of this integration: the system prompt binding selector of the account edit dialog is shown for TypeSafe accounts although System One requests are never injected. The owner decided on 2026-10-03 that it is not offered there; the follow-up change does that. - Final source SHA, PR and check results, image digest and production outcome belong to the operational delivery evidence. From eb7199c1eb15e56d6c5f8879c681c1042bf7ea61 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 19:36:35 +0800 Subject: [PATCH 07/39] fix(codex): keep the models manifest outside the profit gate Upstream selects the account for the Codex models manifest with SelectAccountForModelWithExclusions, which installs no profit gate. The downstream handler selects through the scheduler, so the scheduler's defensive gate applied to the manifest as soon as a group enabled profit control: every account over the threshold was filtered, and a group with none left was answered with 503 "No available OpenAI accounts" where upstream serves the manifest. A veto after the slot would have ended as an empty 200, because this handler has no veto branch. CodexModels now marks its request context with WithOpenAIProfitControlSuppressed before the selection loop, as token counting does. No gate is installed for the request, so neither the filter nor the veto can occur there. TestCodexModelsIgnoresGroupProfitControl: a group with profit control on whose only account is over the threshold still gets its manifest. Co-Authored-By: Claude Opus 5.5 --- .../handler/openai_codex_models_handler.go | 4 ++ .../openai_codex_models_handler_test.go | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/backend/internal/handler/openai_codex_models_handler.go b/backend/internal/handler/openai_codex_models_handler.go index fa4e63efe1fa..d7c85f1a229d 100644 --- a/backend/internal/handler/openai_codex_models_handler.go +++ b/backend/internal/handler/openai_codex_models_handler.go @@ -104,6 +104,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) { var sameAccountRetrySelection *service.AccountSelectionResult var oauth429FailoverState service.OpenAIOAuth429FailoverState + // The manifest is not billed, so the profit gate must not filter the + // accounts that can serve it. + c.Request = c.Request.WithContext(service.WithOpenAIProfitControlSuppressed(c.Request.Context())) + for { retryingSameAccount := sameAccountRetrySelection != nil var selection *service.AccountSelectionResult diff --git a/backend/internal/handler/openai_codex_models_handler_test.go b/backend/internal/handler/openai_codex_models_handler_test.go index 0fdd55aeea80..cfd20eca47cf 100644 --- a/backend/internal/handler/openai_codex_models_handler_test.go +++ b/backend/internal/handler/openai_codex_models_handler_test.go @@ -16,6 +16,7 @@ import ( "time" "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -603,6 +604,61 @@ func TestCodexModelsHonorsAccountSwitchLimit(t *testing.T) { } } +func TestCodexModelsIgnoresGroupProfitControl(t *testing.T) { + gin.SetMode(gin.TestMode) + overThreshold := 0.8 + repo := codexModelsFailoverAccountRepo{accounts: []service.Account{ + { + ID: 1, + Name: "over-threshold", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeAPIKey, + Status: service.StatusActive, + Schedulable: true, + Concurrency: 1, + RateMultiplier: &overThreshold, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://upstream.example/v1", + }, + }, + }} + upstream := &codexModelsFailoverHTTPUpstream{firstBody: `{"models":[{"slug":"gpt-5.6-sol"}]}`} + 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: 46, + Platform: service.PlatformOpenAI, + Status: service.StatusActive, + Hydrated: true, + RateMultiplier: 1, + ProfitControlEnabled: true, + ProfitMinMargin: 0.5, + } + // The auth middleware puts the hydrated group into the request context, + // which is where the profit gate reads its configuration. + requestCtx := context.WithValue(context.Background(), ctxkey.Group, group) + gatedCtx, _ := gatewayService.WithOpenAIRequestPricingContext(requestCtx, &group.ID) + vetoed, _ := service.OpenAIProfitControlVeto(gatedCtx, &repo.accounts[0]) + require.True(t, vetoed, "the group's profit gate must reject the only account") + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.144.0", nil).WithContext(requestCtx) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group}) + + handler.CodexModels(c) + + require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String()) + require.Equal(t, []int64{1}, upstream.calls()) + requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol") +} + func newCodexModelsFailoverTestHandler(firstStatus int) (*OpenAIGatewayHandler, *codexModelsFailoverHTTPUpstream, int64) { return newCodexModelsFailoverTestHandlerWithAccountCount(firstStatus, 2, 3) } From 3f15f06c639ed05920026b9dbe9e8055336d16f5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 19:37:08 +0800 Subject: [PATCH 08/39] docs(downstream): correct the v0.2.13 review after the follow-up review - Reasoning options: create and bulk edit never send the two keys; the edit dialog no longer sets them but still sends them inside the stored extra it passes through. The lead sentence said that all three dialogs no longer send them, against its own sub-bullet. Name what the backend half has to remove for the pass-through to end; no strip code returns to the dialog. - Profit control inside downstream mechanisms: two differences from v0.2.13 were missing. The Codex models manifest selects through the scheduler under the handler's suppress marker, and a same-account retry gets a selection without a gate, so its check after the slot uses the request-level gate. - Validation: the settings purge and the reasoning options change share three files (the two admin/accounts.ts locale files and pages/settings-ops-public.css, in separate hunks); only profit control shares none with the others. List the checks run for the review fixes. - settings-ops-public.css: the hook table names the current SettingsView.vue lines of settings-dlg-foot (6386, 8035, 8088) and settings-list-box (7836). The removed deprecated-settings block moved them up by 27 lines, and three of the four numbers were 12 lines off before that. Comment only. Co-Authored-By: Claude Opus 5.5 --- .downstream/upstream-review-v0.2.13.md | 11 +++++++---- .../src/styles/console/pages/settings-ops-public.css | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.downstream/upstream-review-v0.2.13.md b/.downstream/upstream-review-v0.2.13.md index a22323614d42..056f2803d116 100644 --- a/.downstream/upstream-review-v0.2.13.md +++ b/.downstream/upstream-review-v0.2.13.md @@ -31,9 +31,9 @@ Every official commit is adopted with its official behaviour. Where upstream and - Console theme: no rule and no hook was added. The section has the markup of the peak-rate block above it, and its divider, checkbox, label, hint, field labels and inputs resolve to the same computed styles as their neighbours in light and dark. - A group whose stored `profit_control_enabled` is true is gated from the first start of this release; the stored values were not read here. - **The two retired OpenAI API-key compatibility settings are deleted.** `openai_apikey_alpha_search_responses_bridge_enabled` and `openai_apikey_prompt_cache_key_normalization_enabled` lost their gates and write fields on 2026-09-01 (`cce166e21`). `v0.2.11-codexrip.8` only loaded them, showed them in 系统设置 as 已弃用设置(只读,不生效) and wrote them back unchanged on every save. All of it is removed (owner decision of 2026-10-03): the block and its locale keys, the `alphaSearchMode*` and `promptCacheKeyMode*` account locale keys that no component used, the `deprecated_settings` field of `GET /admin/settings` (the handler ends as upstream's again), the two setting keys with their defaults, parse, update and audit code, the two never-read fields of `OpenAIRefusalRecoveryRuntime`, and the stored rows (migration 266, see Migrations). The account option `openai_prompt_cache_key_mode` and the PAT-only Alpha Search bridge are live and not touched. -- **Per-account reasoning options: the frontend half.** The account create, edit and bulk edit dialogs no longer show or send `openai_chat_reasoning_replay_enabled` and `openai_reasoning_signature_recovery_enabled` (owner decision of 2026-10-03); `OpenAIReasoningPolicyFields.vue`, `utils/openaiReasoningPolicy.ts`, their specs and locale keys are deleted. Invalid reasoning ciphertext recovery gets one global switch on the new page 推理恢复 (`/admin/reasoning-recovery`, sidebar section 扩展功能, after Codex 运行设置): the switch with Reload and Save, no step-up, read and written through `GET` and `PUT /api/v1/admin/reasoning-recovery` as `{"enabled": boolean}`. +- **Per-account reasoning options: the frontend half.** The account create, edit and bulk edit dialogs no longer show `openai_chat_reasoning_replay_enabled` and `openai_reasoning_signature_recovery_enabled` and no longer set them (owner decision of 2026-10-03): create and bulk edit never send the two keys, and the edit dialog sends them only inside the stored `extra` it passes through. `OpenAIReasoningPolicyFields.vue`, `utils/openaiReasoningPolicy.ts`, their specs and locale keys are deleted. Invalid reasoning ciphertext recovery gets one global switch on the new page 推理恢复 (`/admin/reasoning-recovery`, sidebar section 扩展功能, after Codex 运行设置): the switch with Reload and Save, no step-up, read and written through `GET` and `PUT /api/v1/admin/reasoning-recovery` as `{"enabled": boolean}`. - The backend half is not in this tree. The endpoint does not exist, so the page shows its load error and no switch, and the gateway still evaluates the two keys per account as before. - - Until it lands, the edit dialog sends the stored `extra` of an OpenAI account as it is, so saving an account whose stored value of one of the two keys is not a boolean is answered with 400 `OPENAI_REASONING_POLICY_INVALID`. + - Until it lands, the edit dialog sends the stored `extra` of an OpenAI account as it is, so saving an account whose stored value of one of the two keys is not a boolean is answered with 400 `OPENAI_REASONING_POLICY_INVALID`. No code that strips the keys returns to the dialog. The backend half has to end this at its source: remove that validation together with the per-account code and delete both keys from the stored `extra` of every account (a migration numbered 267 or above), after which the dialog has nothing to pass through. - Console theme: the page joins the selectors of the 系统提示词 rules through its own static `data-ui="reasoning-recovery"` hook (`pages/settings-ops-public.css`, and the narrow-screen heading rule of `pages/user.css`); it has no rule of its own. - **Console theme for the new upstream UI.** Behaviour is upstream's; only styling was added under `frontend/src/styles/console`: the eleventh platform segment of the account dialog takes the `sky` identity glyph (`controls.css` 7.1a); the priority control sits as the value of the stacked priority line with label-toned steppers (`pages/accounts.css` 5, reached through the component's own static `data-testid` attributes); the mode toggle of `RechargeBonusTierEditor` carries the static `data-ui="seg-box"` hook. No `:has()`, no runtime-bound hook. - One existing rule also reaches the new UI without a change: the `RechargeBonusTierEditor` card has the same header and `p-4` body as the payment provider card beside it, so the positional body rule of `pages/settings-ops-public.css` 2.2 gives it the settings card padding. The comment of that rule now names both cards. @@ -62,10 +62,12 @@ Every official commit is adopted with its official behaviour. Where upstream and - **Codex catalogs of composite groups exclude TypeSafe**, as upstream: `compositeAvailableModels(..., includeSystemOne=false)` and the static composite defaults never list `jev-latest`. A group whose own platform is `typesafe` is not covered by that exclusion, also as upstream: `GET /v1/models?client_version=...` reaches `GatewayHandler.CodexModels` for every non-OpenAI group, and its manifest lists `jev-latest` (from the account mappings or `defaultModelIDsForPlatform`), with no capacity unless an account override or observation supplies one. - **Profit control inside downstream mechanisms.** The profit code differs from v0.2.13 only where a downstream contract wraps it: - a selection result also carries the runtime-breaker probe (`attachSelectionProfitGate(ctx, attachSelectionRuntimeBreakerProbe(...))`), and a vetoed slot releases that probe together with the slot; + - a same-account retry does not select again: the downstream `ReacquireOpenAISameAccountSelection` returns a selection that carries no gate, and the check after the slot then runs against the request-level gate, which every text handler installs with `WithOpenAIRequestPricingContext` for the group it schedules; - the previous-response lookup reads the stored owner from the repository once, so the veto is checked once instead of upstream's twice, and a veto is the downstream policy miss: reuse is skipped, the binding stays, ordinary scheduling follows; - a Codex quality run is pinned to its account: a veto answers 409 `codex_quality_unavailable` instead of reselecting, and the run binds no sticky session; - the WebSocket turn veto closes the turn with the downstream local close error; - token counting selects through the scheduler under the handler's suppress marker, so upstream's `SelectAccountForTokenCount` is not used; + - the Codex models manifest selects through the scheduler as well, where upstream uses `SelectAccountForModelWithExclusions`, which installs no gate; its handler sets the suppress marker before the selection loop, so the manifest stays outside the gate as upstream (`TestCodexModelsIgnoresGroupProfitControl`); - the eligibility helpers return `(bool, reason)` for the downstream selection diagnostics, and the handlers pass the pricing instant through the downstream usage snapshot; - the auth snapshot version is the downstream counter (28; upstream 24). - Image Studio and the Responses image bridge stay off and the public model page stays retired; no upstream change in this range touches them. No upstream change touches the per-account reasoning options either; what changes for them is the downstream follow-up described under Integration decisions. @@ -99,7 +101,7 @@ Every official commit is adopted with its official behaviour. Where upstream and ## Validation -- Every bullet before "After the review fixes" describes the checks of the integration before its review; that bullet lists what was run again on the final tree of the integration, and the two after it cover the follow-ups on top of it. +- Every bullet before "After the review fixes" describes the checks of the integration before its review; that bullet lists what was run again on the final tree of the integration, and the three after it cover the follow-ups on top of it, the last of them the fixes after their review. - `go build ./...` passes for windows and for `GOOS=linux GOARCH=amd64`. `go vet ./...` passes plain and with each of the `unit`, `integration` and `e2e` tags, so every merged test file compiles. `gofmt -l` is empty for the Go files that differ from the downstream base. - Focused `go test -tags=unit` runs pass: `migrations` (both TypeSafe migration tests); `service` (TypeSafe connection test, connection test model, catalog auto-refresh eligibility, composite capacity pool, `jev-latest` pricing, `ForwardSystemOne`, Antigravity client error body, TypeSafe account creation with the billing probe, platform lists, Grok headers, recharge bonus quote and rebate base, reset token); `handler` (System One, protocol rejection, model lists); `handler/admin` (available models, group platform binding, pricing sync); `server` (admin settings contract); `server/routes` (public order verify rate limit, prompt audit route coverage, group model allowlist routes); `repository` (API key sort, deleted-key usage settlement, email cache, Grok CLI identity); `model` (platform list). - `golangci-lint` 2.13 reports 0 issues on `ent`, `domain`, `handler`, `model`, `pkg/typesafe`, `pkg/xai`, `repository`, `securityaudit`, `server`, `service` and `migrations`. @@ -111,7 +113,8 @@ Every official commit is adopted with its official behaviour. Where upstream and - After the review fixes (System One Ops body, connection test success rule, `cindy` in the 241 quota list, the CSS comment), on the final tree: `go build ./...` (windows) and `go vet -tags=unit` on `pkg/typesafe`, `service` and `migrations` pass, and `gofmt -l` is empty for the six changed Go files. Focused tests pass: `pkg/typesafe` (`TestEvaluateContract` and both `DecodeSystemOneResponse` tests), `migrations` (both TypeSafe migration tests), `service` (the five TypeSafe connection tests; the `ForwardSystemOne` error policy, request error, account-level failover, oversized response and response-without-model tests; the new Ops record test). `golangci-lint` 2.13 reports 0 issues on `pkg/typesafe`, `migrations` and `service`. Not repeated: the linux build, the `integration` and `e2e` vet runs, the other packages and every frontend check. The fixes changed no platform-specific code, no tagged test file and no other package, and in the frontend only a CSS comment. - After merging `origin/main` (`291d25e7f`, #233): one conflict, `backend/internal/service/admin_account.go`, where upstream's TypeSafe API-key-only check stays and the `maps.Clone(accountExtra)` line that #233 removed stays removed. `.downstream/upstream-risk.json` was regenerated against the new downstream commit (1420 downstream paths; 157 upstream, 65 overlapping and 31 critical as before). On that tree `go build ./...` and `go vet -tags=unit` on `service`, `handler`, `repository`, `server` and `migrations` pass, the two TypeSafe migration tests and the `service` tests matching `TypeSafe` pass, and `test_upstream_risk.py` and `test_upstream_automation_contract.py` pass. No frontend check was repeated locally. - Profit control, on top of the integration: `go build ./...` (windows) passes; `go vet -tags=unit` passes on `service`, `handler`, `handler/admin`, `handler/dto` and `cmd/profit-preview`; `gofmt -l` is empty for the changed Go files; `golangci-lint` 2.13 reports 0 issues on the same five packages. Upstream's profit tests pass unchanged against the downstream scheduler: 45 in `service` (gate resolution, veto reasons, scheduler and legacy filtering, previous-response reuse, pricing context, the shared-gateway gate, auth snapshot roundtrip and eviction, preview), 10 in `handler` (veto budget, failover loop, slot recheck), 2 in `handler/dto`, 3 in `cmd/profit-preview`. Also passing: the two simple-mode group tests, the other auth snapshot tests, the selection failure statistics tests, the group duplicate tests and the group handler tests. `vue-tsc --noEmit` passes; vitest passes for `groupsProfitControl.spec.ts`, the four `GroupsView` specs and locale key completeness; ESLint passes on the changed frontend files. A local preview against a mock API showed the section in the create and edit dialogs in light and dark (off, on with both numbers, the sum error, a subscription group with the peak-rate block, 390 px); the captures are not part of the repository. Not run for this change: the linux build, the `integration` and `e2e` vet runs, and the two database-backed profit tests in `repository`, which are unchanged upstream files. -- The follow-ups on one tree (profit control, the settings purge, the reasoning options; their change sets share no file): `go build ./...` (windows) passes; `go vet` passes plain and with the `unit` tag on `cmd/profit-preview`, `handler`, `handler/admin`, `handler/dto`, `repository`, `server` and `service`, and with the `integration` tag on `repository`; `gofmt -l` is empty for the changed Go files; `vue-tsc --noEmit`, locale key completeness and ESLint on the changed frontend files pass; `test_upstream_risk.py` and `test_upstream_automation_contract.py` pass. `.downstream/upstream-risk.json` needs no regeneration: it is computed from the two upstream tags and the downstream base alone, and recomputing it reproduces every key the gate compares. Not run on the combined tree: Go tests, the other vitest specs, `golangci-lint`, the linux build, and migration 266 against a database. +- The follow-ups on one tree (profit control, the settings purge, the reasoning options; profit control shares no file with the other two, which both change the two `admin/accounts.ts` locale files and `pages/settings-ops-public.css`, in separate hunks): `go build ./...` (windows) passes; `go vet` passes plain and with the `unit` tag on `cmd/profit-preview`, `handler`, `handler/admin`, `handler/dto`, `repository`, `server` and `service`, and with the `integration` tag on `repository`; `gofmt -l` is empty for the changed Go files; `vue-tsc --noEmit`, locale key completeness and ESLint on the changed frontend files pass; `test_upstream_risk.py` and `test_upstream_automation_contract.py` pass. `.downstream/upstream-risk.json` needs no regeneration: it is computed from the two upstream tags and the downstream base alone, and recomputing it reproduces every key the gate compares. Not run on the combined tree: Go tests, the other vitest specs, `golangci-lint`, the linux build, and migration 266 against a database. +- The fixes after the review of the follow-ups (the Codex models manifest outside the profit gate, the hook table of `pages/settings-ops-public.css`, corrections in this document): `go vet` passes plain and with the `unit` tag on `handler`; `gofmt -l` is empty for the two changed Go files; `golangci-lint` 2.13 reports 0 issues on `handler`. `TestCodexModelsIgnoresGroupProfitControl` (a group with profit control on whose only account is over the threshold) was answered with 503 `No available OpenAI accounts` before the handler change and passes after it, plain and with the `unit` tag; the other 22 Codex models tests of `handler` pass. Not run: any other package or test, and every frontend check; the frontend change is inside a CSS comment. - Not run: full suites, race tests, integration tests against a database, a new-database, older-database or production migration rehearsal, real model or TypeSafe calls, deploy. Source integration does not claim a successful deployment. ## Release target diff --git a/frontend/src/styles/console/pages/settings-ops-public.css b/frontend/src/styles/console/pages/settings-ops-public.css index b86b6a3f4307..a18a8cb6d2c4 100644 --- a/frontend/src/styles/console/pages/settings-ops-public.css +++ b/frontend/src/styles/console/pages/settings-ops-public.css @@ -2408,9 +2408,9 @@ html.flat-theme [data-ui="key-usage"] .skeleton { * views/admin/SettingsView.vue (84 switch rows) data-ui="setting-row" (3.1) * views/admin/SettingsView.vue (2 matrix rows) data-ui="quota-row" (6.x the platform quota matrices) * views/admin/SettingsView.vue:1863 data-ui="card-title-icon" (2.1 the glyph before a card title) - * views/admin/SettingsView.vue:6413, 8050, 8103 data-ui="settings-dlg-foot" (10 the inline dialogs' button rows) + * views/admin/SettingsView.vue:6386, 8035, 8088 data-ui="settings-dlg-foot" (10 the inline dialogs' button rows) * views/admin/BackupView.vue:347 data-ui="r2-table-box" (the R2 guide's settings table frame) - * views/admin/SettingsView.vue:7851, BackupView.vue:203 data-ui="settings-list-box" (6.3 the two lists that can be empty) + * views/admin/SettingsView.vue:7836, BackupView.vue:203 data-ui="settings-list-box" (6.3 the two lists that can be empty) * Existing hooks used: .settings-tabs-* (SettingsView), #provider-form (PaymentProviderDialog), data-testid * "official-model-catalog*", "image-tools-settings", "observability-settings", "backup-schedule", "retired-plugins", * data-test="account-system-prompt-binding" (the per-account binding in the accounts dialogs). From 2224b35543485bf4d561fa388579dada0b3652f5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 4 Oct 2026 01:07:32 +0800 Subject: [PATCH 09/39] =?UTF-8?q?fix(reasoning):=20drop=20Reload=20from=20?= =?UTF-8?q?=E6=8E=A8=E7=90=86=E6=81=A2=E5=A4=8D=20and=20give=20its=20entry?= =?UTF-8?q?=20an=20own=20icon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decisions after the preview of 2026-10-03. - The page has the switch and Save only. The Reload button, its locale key in both locales and its spec case are gone. The value is read once when the page is opened: load() no longer has a second caller, so the reset of the error before a read and the two guards that only a second read needed (loading while a draft exists) went with the button. - A failed read shows its error and no switch, with Save disabled, until the page is opened again. That is how the pages without a reload button behave (Codex 运行设置, 系统设置); no control was added for it. - Sidebar: the entry used the shield of 安全审计 and 操作日志. It now has RecoveryIcon, the turn-back arrow (Heroicons arrow-uturn-left), defined like the other inline icons and mapped for the console theme to the new Icon.vue name undo, whose Lucide glyph is undo-2 (verbatim from lucide-static 0.469.0). No other entry uses the icon, the path or the name. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/icons/Icon.vue | 1 + frontend/src/components/icons/lucide.ts | 2 ++ frontend/src/components/layout/AppSidebar.vue | 19 ++++++++-- .../layout/__tests__/AppSidebar.spec.ts | 14 ++++++++ .../locales/en/admin/reasoningRecovery.ts | 1 - .../locales/zh/admin/reasoningRecovery.ts | 1 - .../src/views/admin/ReasoningRecoveryView.vue | 9 ++--- .../__tests__/ReasoningRecoveryView.spec.ts | 35 +++++++++++-------- 8 files changed, 57 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/icons/Icon.vue b/frontend/src/components/icons/Icon.vue index 2f6bc01cd0af..e3c496d8773e 100644 --- a/frontend/src/components/icons/Icon.vue +++ b/frontend/src/components/icons/Icon.vue @@ -161,6 +161,7 @@ const icons = { signal: 'M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z', clipboardList: 'M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z', receipt: 'M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z', + undo: 'M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3', chevronDoubleLeft: 'm18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5', chevronDoubleRight: 'm5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5', banknotes: 'M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z', diff --git a/frontend/src/components/icons/lucide.ts b/frontend/src/components/icons/lucide.ts index 7bae2d528810..dcec0bab5d3a 100644 --- a/frontend/src/components/icons/lucide.ts +++ b/frontend/src/components/icons/lucide.ts @@ -128,6 +128,7 @@ const nodes = { 'activity': [['path', { d: 'M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2' }]], 'clipboard-list': [['rect', { width: '8', height: '4', x: '8', y: '2', rx: '1', ry: '1' }], ['path', { d: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2' }], ['path', { d: 'M12 11h4' }], ['path', { d: 'M12 16h4' }], ['path', { d: 'M8 11h.01' }], ['path', { d: 'M8 16h.01' }]], 'receipt-text': [['path', { d: 'M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z' }], ['path', { d: 'M14 8H8' }], ['path', { d: 'M16 12H8' }], ['path', { d: 'M13 16H8' }]], + 'undo-2': [['path', { d: 'M9 14 4 9l5-5' }], ['path', { d: 'M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11' }]], 'panel-left-close': [['rect', { width: '18', height: '18', x: '3', y: '3', rx: '2' }], ['path', { d: 'M9 3v18' }], ['path', { d: 'm16 15-3-3 3-3' }]], 'panel-left-open': [['rect', { width: '18', height: '18', x: '3', y: '3', rx: '2' }], ['path', { d: 'M9 3v18' }], ['path', { d: 'm14 9 3 3-3 3' }]], 'wallet': [['path', { d: 'M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1' }], ['path', { d: 'M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4' }]], @@ -232,6 +233,7 @@ export const lucideIcons = { signal: glyph('activity'), // channel status: the reference Logs pulse line clipboardList: glyph('clipboard-list'), receipt: glyph('receipt-text'), + undo: glyph('undo-2'), // the 推理恢复 entry: the turn-back arrow, like arrow-uturn-left chevronDoubleLeft: glyph('panel-left-close'), // collapse the sidebar, the reference's glyph chevronDoubleRight: glyph('panel-left-open'), banknotes: glyph('wallet'), // the header balance diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index e3de07be00e1..7a97275ba225 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -709,6 +709,21 @@ const ShieldIcon = { ) } +const RecoveryIcon = { + render: () => + h( + 'svg', + { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, + [ + h('path', { + 'stroke-linecap': 'round', + 'stroke-linejoin': 'round', + d: 'M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3' + }) + ] + ) +} + const PriceTagIcon = { render: () => h( @@ -753,7 +768,7 @@ const consoleThemeIcons: Array<[typeof DashboardIcon, InstanceType[ [BellIcon, 'bell'], [TicketIcon, 'ticket'], [CogIcon, 'cog'], [DocumentIcon, 'document'], [SunIcon, 'sun'], [MoonIcon, 'moon'], [ChevronDoubleLeftIcon, 'chevronDoubleLeft'], [ChevronDoubleRightIcon, 'chevronDoubleRight'], [OrderIcon, 'clipboardList'], [OrderListIcon, 'receipt'], [SignalIcon, 'signal'], [ShieldIcon, 'shield'], - [PriceTagIcon, 'tag'], [ChevronDownIcon, 'chevronDown'] + [RecoveryIcon, 'undo'], [PriceTagIcon, 'tag'], [ChevronDownIcon, 'chevronDown'] ] for (const [icon, name] of consoleThemeIcons) { const upstream = icon.render @@ -838,7 +853,7 @@ function buildExtensionNavItems(includeAdmin: boolean): NavItem[] { if (includeAdmin) { items.push({ path: '/admin/system-prompts', label: t('nav.systemPrompts'), icon: DocumentIcon }) items.push({ path: '/admin/codex-runtime', label: t('nav.codexRuntime'), icon: PluginIcon }) - items.push({ path: '/admin/reasoning-recovery', label: t('nav.reasoningRecovery'), icon: ShieldIcon }) + items.push({ path: '/admin/reasoning-recovery', label: t('nav.reasoningRecovery'), icon: RecoveryIcon }) } items.push({ path: '/image-studio', label: t('nav.imageStudio'), icon: BatchImageIcon, featureFlag: flagImageStudio }) return items diff --git a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts index 7fec4d733751..cf40267da05a 100644 --- a/frontend/src/components/layout/__tests__/AppSidebar.spec.ts +++ b/frontend/src/components/layout/__tests__/AppSidebar.spec.ts @@ -78,6 +78,20 @@ describe('AppSidebar extensions section', () => { expect(imageStudioItem).toContain('featureFlag: flagImageStudio') expect(imageStudioItem).not.toContain('hideInSimpleMode') }) + + it('gives 推理恢复 an icon no other entry uses, in both themes', () => { + const recoveryItem = componentSource.match(/\{ path: '\/admin\/reasoning-recovery'[^\n]+\}/)?.[0] + + expect(recoveryItem).toContain('icon: RecoveryIcon') + expect(componentSource.match(/icon: RecoveryIcon\b/g)).toHaveLength(1) + // no other inline icon draws the same path + const recoveryPath = componentSource.match(/const RecoveryIcon = \{[\s\S]*?d: '([^']+)'/)?.[1] + expect(recoveryPath).toBeDefined() + expect(componentSource.split(`d: '${recoveryPath}'`)).toHaveLength(2) + // the console theme draws it through Icon.vue: no other inline icon maps to that name + expect(componentSource).toContain("[RecoveryIcon, 'undo']") + expect(componentSource.match(/'undo'/g)).toHaveLength(1) + }) }) describe('AppSidebar subscription feature flag', () => { diff --git a/frontend/src/i18n/locales/en/admin/reasoningRecovery.ts b/frontend/src/i18n/locales/en/admin/reasoningRecovery.ts index a61156b9fa44..860a74ea291a 100644 --- a/frontend/src/i18n/locales/en/admin/reasoningRecovery.ts +++ b/frontend/src/i18n/locales/en/admin/reasoningRecovery.ts @@ -7,7 +7,6 @@ export default { scope: 'Applies only to requests actually sent over HTTP/SSE on the native Responses, Compact and Chat-to-Responses paths. Messages, WebSocket (including HTTP ingress routed to WS) and native Chat-only upstreams are not covered. The 24 hours are a logical limit; Redis persistence files and backups may retain historical bytes.', save: 'Save', saved: 'Reasoning recovery setting saved', - reload: 'Refresh', loadFailed: 'Loading failed', saveFailed: 'Saving failed' } diff --git a/frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts b/frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts index d8290fc2574c..7f249631c772 100644 --- a/frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts +++ b/frontend/src/i18n/locales/zh/admin/reasoningRecovery.ts @@ -7,7 +7,6 @@ export default { scope: '仅适用于实际 HTTP/SSE 的原生 Responses、Compact 与 Chat→Responses 路径。Messages、WebSocket(包括 HTTP 入口转 WS)、原生 Chat-only 上游不启用。24 小时是逻辑期限,Redis 持久化文件与备份可能保留历史字节。', save: '保存', saved: '推理恢复设置已保存', - reload: '刷新', loadFailed: '加载失败', saveFailed: '保存失败' } diff --git a/frontend/src/views/admin/ReasoningRecoveryView.vue b/frontend/src/views/admin/ReasoningRecoveryView.vue index 5d8c52c683dd..ace8ba5fd39c 100644 --- a/frontend/src/views/admin/ReasoningRecoveryView.vue +++ b/frontend/src/views/admin/ReasoningRecoveryView.vue @@ -6,14 +6,11 @@

{{ t('admin.reasoningRecovery.title') }}

{{ t('admin.reasoningRecovery.description') }}

-
- - -
+ -

{{ t('common.loading') }}

+

{{ t('common.loading') }}