diff --git a/README_CN.md b/README_CN.md index 3116e7c5fa..725e27d844 100644 --- a/README_CN.md +++ b/README_CN.md @@ -227,6 +227,30 @@ underscores_in_headers on; Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`),这会导致多账号环境下的粘性会话功能失效。 +## Codex Fast/Flex 策略说明 + +管理员后台的 `系统设置 -> 网关服务 -> OpenAI Fast/Flex 策略` 只负责处理请求体中的 `service_tier`,不会修改 Codex 客户端的模型目录,也不会让 Codex UI 自动出现 Speed 或 `/fast` 选项。 + +策略支持以下处理方式: + +- `pass`:保留客户端传入的 `service_tier`;`fast` 会规范为上游使用的 `priority`。 +- `filter`:移除 `service_tier`,按普通优先级请求。 +- `block`:拒绝匹配的 Fast/Flex 请求。 +- `force_priority`:将匹配请求强制设置为 `priority`,会按 Priority/Fast 价格计费。为避免升级后改变既有规则语义,`all` 只匹配显式存在的 tier;如需让省略 `service_tier` 的 OpenAI 请求也强制升级,必须新增 `service_tier=missing + force_priority` 规则。非 OpenAI 平台不会执行缺省 tier 注入。这种方式可以让请求实际使用 Fast,但 Codex UI 仍可能不显示 Fast 状态。 + +Codex 的 Fast 入口由客户端模型目录驱动。只有当前模型目录声明了 `additional_speed_tiers: ["fast"]` 和对应的 `service_tiers`,Codex 才会显示 `/fast`。通过 API Key 或自定义模型提供商连接 Sub2API 时,如果模型目录没有这些字段,即使后台配置了 `force_priority`,重启 Codex 后也不会出现 Speed 选项。 + +客户端可在 `~/.codex/config.toml` 中直接指定默认请求级别: + +```toml +service_tier = "fast" + +[features] +fast_mode = true +``` + +其中 `service_tier = "fast"` 会让请求携带 Fast 设置;`features.fast_mode` 只启用客户端 Fast 功能。模型目录没有声明 Fast 能力时,`/fast` 仍可能不显示。可通过 Sub2API 使用记录确认最终 `service_tier` 是否为 `priority`。 + --- ## 部署方式 diff --git a/backend/internal/service/openai_fast_policy_test.go b/backend/internal/service/openai_fast_policy_test.go index a29e7ffd21..6a0a2080a5 100644 --- a/backend/internal/service/openai_fast_policy_test.go +++ b/backend/internal/service/openai_fast_policy_test.go @@ -205,6 +205,64 @@ func TestApplyOpenAIFastPolicyToBody_DefaultPassesPriorityAndFast(t *testing.T) require.Equal(t, string(body), string(updated)) } +func TestApplyOpenAIFastPolicyToBody_ForcePriorityInjectsMissingTier(t *testing.T) { + settings := &OpenAIFastPolicySettings{ + Rules: []OpenAIFastPolicyRule{{ + ServiceTier: OpenAIFastTierMissing, + Action: OpenAIFastPolicyActionForcePriority, + Scope: BetaPolicyScopeAll, + UserIDs: []int64{42}, + }}, + } + svc := newOpenAIGatewayServiceWithSettings(t, settings) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + body := []byte(`{"model":"gpt-5.5"}`) + + matchingCtx := context.WithValue(context.Background(), ctxkey.UserID, int64(42)) + updated, err := svc.applyOpenAIFastPolicyToBody(matchingCtx, account, "gpt-5.5", body) + require.NoError(t, err) + require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String()) + + otherCtx := context.WithValue(context.Background(), ctxkey.UserID, int64(43)) + updated, err = svc.applyOpenAIFastPolicyToBody(otherCtx, account, "gpt-5.5", body) + require.NoError(t, err) + require.False(t, gjson.GetBytes(updated, "service_tier").Exists()) +} + +func TestApplyOpenAIFastPolicyToBody_LegacyAllRuleDoesNotInjectMissingTier(t *testing.T) { + settings := &OpenAIFastPolicySettings{ + Rules: []OpenAIFastPolicyRule{{ + ServiceTier: OpenAIFastTierAny, + Action: OpenAIFastPolicyActionForcePriority, + Scope: BetaPolicyScopeAll, + }}, + } + svc := newOpenAIGatewayServiceWithSettings(t, settings) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + body := []byte(`{"model":"gpt-5.5"}`) + + updated, err := svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-5.5", body) + + require.NoError(t, err) + require.Equal(t, string(body), string(updated)) + + // Explicitly selected tiers continue to be forced by the existing rule. + body = []byte(`{"model":"gpt-5.5","service_tier":"flex"}`) + updated, err = svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-5.5", body) + require.NoError(t, err) + require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String()) +} + +func TestApplyOpenAIFastPolicyToBody_MissingTierIgnoresNonForceRule(t *testing.T) { + svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy()) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} + body := []byte(`{"model":"gpt-5.5"}`) + + updated, err := svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-5.5", body) + require.NoError(t, err) + require.Equal(t, string(body), string(updated)) +} + func TestApplyOpenAIFastPolicyToBody_ExplicitFilterRemovesField(t *testing.T) { svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy()) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} @@ -505,16 +563,23 @@ func TestSetOpenAIFastPolicySettings_Validation(t *testing.T) { Action: BetaPolicyActionPass, Scope: BetaPolicyScopeAll, }, + { + ServiceTier: OpenAIFastTierMissing, + Action: OpenAIFastPolicyActionForcePriority, + Scope: BetaPolicyScopeAll, + }, }, }) require.NoError(t, err) got, err := svc.GetOpenAIFastPolicySettings(context.Background()) require.NoError(t, err) - require.Len(t, got.Rules, 2) + require.Len(t, got.Rules, 3) require.Equal(t, OpenAIFastTierPriority, got.Rules[0].ServiceTier) + require.Equal(t, OpenAIFastTierUltrafast, got.Rules[1].ServiceTier) + require.Equal(t, OpenAIFastTierMissing, got.Rules[2].ServiceTier) require.Equal(t, OpenAIFastPolicyActionForcePriority, got.Rules[0].Action) require.Equal(t, []int64{42, 43}, got.Rules[0].UserIDs) - require.Equal(t, OpenAIFastTierUltrafast, got.Rules[1].ServiceTier) require.Equal(t, BetaPolicyActionPass, got.Rules[1].Action) + require.Equal(t, OpenAIFastPolicyActionForcePriority, got.Rules[2].Action) } diff --git a/backend/internal/service/openai_fast_policy_ws_test.go b/backend/internal/service/openai_fast_policy_ws_test.go index c154f32abf..7bc2d5f4dc 100644 --- a/backend/internal/service/openai_fast_policy_ws_test.go +++ b/backend/internal/service/openai_fast_policy_ws_test.go @@ -136,6 +136,24 @@ func TestWSResponseCreate_GroupForceInjectsMissingTier(t *testing.T) { require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String()) } +func TestWSResponseCreate_ForcePriorityInjectsMissingTier(t *testing.T) { + settings := &OpenAIFastPolicySettings{ + Rules: []OpenAIFastPolicyRule{{ + ServiceTier: OpenAIFastTierMissing, + Action: OpenAIFastPolicyActionForcePriority, + Scope: BetaPolicyScopeAll, + }}, + } + svc := newOpenAIGatewayServiceWithSettings(t, settings) + account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} + frame := []byte(`{"type":"response.create","model":"gpt-5.5"}`) + + updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame) + require.NoError(t, err) + require.Nil(t, blocked) + require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String()) +} + func TestWSResponseCreate_GroupForceDoesNotTouchOtherFrames(t *testing.T) { svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings()) account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth} diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 2a459cc286..7f899d2c34 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -681,6 +681,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } } + } else if s.shouldForceOpenAIFastPriorityForMissingTier(ctx, account, upstreamModel) { + markPatchSet("service_tier", OpenAIFastTierPriority) } if account.UsesOpenAICodexProtocol() { diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 3f06c5105d..e26197ed1e 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -1605,7 +1605,7 @@ func (e *OpenAIFastBlockedError) Error() string { return e.Message } // Matching rules: // - Scope filters by account type (all / oauth / apikey / bedrock) // - UserIDs, when present, filters by the trusted Sub2API user that owns the API key -// - ServiceTier must be empty (= any), "all", or equal the normalized tier +// - ServiceTier must be empty (= any), "all", "missing", or equal the normalized tier // - ModelWhitelist narrows the rule to specific models; FallbackAction // handles the non-matching case (default: pass) // - User-specific rules take precedence over global rules; each group keeps @@ -1638,6 +1638,18 @@ func (s *OpenAIGatewayService) evaluateOpenAIFastPolicy(ctx context.Context, acc return evaluateOpenAIFastPolicyWithSettings(settings, openAIFastPolicyUserID(ctx), account, model, tier) } +// shouldForceOpenAIFastPriorityForMissingTier reports whether a request that +// omitted service_tier should be upgraded to priority. This is opt-in through +// the dedicated "missing" tier matcher; legacy "all" rules continue to apply +// only to requests that explicitly selected a recognized tier. +func (s *OpenAIGatewayService) shouldForceOpenAIFastPriorityForMissingTier(ctx context.Context, account *Account, model string) bool { + if account == nil || account.Platform != PlatformOpenAI { + return false + } + action, _ := s.evaluateOpenAIFastPolicy(ctx, account, model, OpenAIFastTierMissing) + return action == OpenAIFastPolicyActionForcePriority +} + // evaluateOpenAIFastPolicyWithSettings is the pure-function core extracted so // long-lived sessions (e.g. WS) can prefetch settings once and avoid hitting // the settingService on every frame. See WSSession entry and @@ -1660,7 +1672,11 @@ func evaluateOpenAIFastPolicyWithSettings(settings *OpenAIFastPolicySettings, us continue } ruleTier := strings.ToLower(strings.TrimSpace(rule.ServiceTier)) - if ruleTier != "" && ruleTier != OpenAIFastTierAny && ruleTier != tier { + if tier == OpenAIFastTierMissing { + if ruleTier != OpenAIFastTierMissing { + continue + } + } else if ruleTier != "" && ruleTier != OpenAIFastTierAny && ruleTier != tier { continue } eff := BetaPolicyRule{ @@ -1765,6 +1781,13 @@ func (s *OpenAIGatewayService) applyOpenAIFastPolicyToBody(ctx context.Context, } rawTier := gjson.GetBytes(body, "service_tier").String() if rawTier == "" { + if s.shouldForceOpenAIFastPriorityForMissingTier(ctx, account, model) { + updated, err := sjson.SetBytes(body, "service_tier", OpenAIFastTierPriority) + if err != nil { + return body, fmt.Errorf("force missing service_tier priority on body: %w", err) + } + return updated, nil + } return body, nil } normTier := normalizedOpenAIServiceTierValue(rawTier) @@ -1884,6 +1907,13 @@ func (s *OpenAIGatewayService) applyOpenAIFastPolicyToWSResponseCreate( } rawTier := gjson.GetBytes(frame, "service_tier").String() if rawTier == "" { + if s.shouldForceOpenAIFastPriorityForMissingTier(ctx, account, model) { + updated, err := sjson.SetBytes(frame, "service_tier", OpenAIFastTierPriority) + if err != nil { + return frame, nil, fmt.Errorf("force missing service_tier priority in ws frame: %w", err) + } + return updated, nil, nil + } return frame, nil, nil } normTier := normalizedOpenAIServiceTierValue(rawTier) diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go index 3827790fe8..f282761b58 100644 --- a/backend/internal/service/setting_features.go +++ b/backend/internal/service/setting_features.go @@ -1017,6 +1017,7 @@ func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settin } validTiers := map[string]bool{ OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierUltrafast: true, OpenAIFastTierFlex: true, + OpenAIFastTierMissing: true, } for i, rule := range settings.Rules { diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 93e7d76bcb..83c1422568 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -683,7 +683,7 @@ func DefaultBetaPolicySettings() *BetaPolicySettings { // - "priority"(客户端可传 "fast",归一化为 "priority"):fast 模式 // - "ultrafast":Codex/API 的 Ultrafast 档位 // - "flex":低优先级模式 -// - 省略:normal 默认 +// - 省略:normal 默认;策略中可用专用 "missing" 条件显式匹配 // // 本策略复用 BetaPolicyAction*/BetaPolicyScope* 常量语义,只是匹配键从 // anthropic-beta header 换成 body 的 service_tier 字段。 @@ -692,6 +692,7 @@ const ( OpenAIFastTierPriority = "priority" // 仅匹配 fast(priority) OpenAIFastTierUltrafast = "ultrafast" // 仅匹配 ultrafast OpenAIFastTierFlex = "flex" // 仅匹配 flex + OpenAIFastTierMissing = "missing" // 仅匹配省略 service_tier 的请求 // OpenAIFastPolicyActionForcePriority 会保留 service_tier 字段并强制写成 // priority,用于把 flex/auto/default/scale 等已识别 tier 收敛为 fast。 @@ -700,7 +701,7 @@ const ( // OpenAIFastPolicyRule 单条 OpenAI fast/flex 策略规则 type OpenAIFastPolicyRule struct { - ServiceTier string `json:"service_tier"` // "priority" | "ultrafast" | "flex" | "auto" | "default" | "scale" | "all" + ServiceTier string `json:"service_tier"` // "priority" | "ultrafast" | "flex" | "missing" | "all" Action string `json:"action"` // "pass" | "filter" | "block" | "force_priority" Scope string `json:"scope"` // "all" | "oauth" | "apikey" | "bedrock" UserIDs []int64 `json:"user_ids,omitempty"` // 空=所有 Sub2API 用户;非空=仅指定 API Key 所属用户 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 9404916394..4c8c06a7a3 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -1441,7 +1441,7 @@ export async function updateRectifierSettings( * Matches backend dto.OpenAIFastPolicyRule. */ export interface OpenAIFastPolicyRule { - service_tier: "all" | "priority" | "flex" | "ultrafast"; + service_tier: "all" | "priority" | "flex" | "ultrafast" | "missing"; action: "pass" | "filter" | "block" | "force_priority"; scope: "all" | "oauth" | "apikey" | "bedrock"; user_ids?: number[]; diff --git a/frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts b/frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts index 422db45776..cea1adfd1b 100644 --- a/frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts +++ b/frontend/src/i18n/__tests__/openaiFastPolicyLocales.spec.ts @@ -32,6 +32,7 @@ describe('OpenAI Fast/Flex policy locale keys', () => { expect(zh.admin.settings.openaiFastPolicy).toMatchObject({ tierAll: '全部 tier 值', tierUltrafast: 'ultrafast', + tierMissing: '省略 tier', modelWhitelist: '目标模型', fallbackAction: '其他模型处理方式', summaryTargetModels: '目标模型', @@ -45,6 +46,7 @@ describe('OpenAI Fast/Flex policy locale keys', () => { expect(en.admin.settings.openaiFastPolicy).toMatchObject({ tierAll: 'All tier values', tierUltrafast: 'ultrafast', + tierMissing: 'Omitted tier', modelWhitelist: 'Target models', fallbackAction: 'Other models action', summaryTargetModels: 'Target models', diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts index 57c78d3cec..99e82fb12b 100644 --- a/frontend/src/i18n/locales/en/admin/settings.ts +++ b/frontend/src/i18n/locales/en/admin/settings.ts @@ -1100,7 +1100,7 @@ export default { }, openaiFastPolicy: { title: 'OpenAI Fast/Flex Policy', - description: 'Intercept, filter, or pass OpenAI fast(priority), ultrafast, or flex requests based on the request body service_tier field. Applies to the OpenAI gateway only.', + description: 'Intercept, filter, or pass OpenAI fast(priority), ultrafast, or flex requests based on the request body service_tier field. Applies to the OpenAI gateway only. "All tier values" includes explicitly sent tiers only.', empty: 'No rules configured. Click the button below to add one.', ruleHeader: 'Rule #{index}', removeRule: 'Remove rule', @@ -1108,6 +1108,7 @@ export default { saveHint: 'Saved together with system settings (click the global Save button at the bottom of the page).', serviceTier: 'service_tier match', tierAll: 'All tier values', + tierMissing: 'Omitted tier', tierPriority: 'priority (fast)', tierUltrafast: 'ultrafast', tierFlex: 'flex', diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts index 463f4f671f..df178cb67d 100644 --- a/frontend/src/i18n/locales/zh/admin/settings.ts +++ b/frontend/src/i18n/locales/zh/admin/settings.ts @@ -1094,7 +1094,7 @@ export default { }, openaiFastPolicy: { title: 'OpenAI Fast/Flex 策略', - description: '基于请求体 service_tier 字段拦截/过滤/透传 OpenAI fast(priority)、ultrafast 与 flex 请求;仅作用于 OpenAI 网关。', + description: '基于请求体 service_tier 字段拦截/过滤/透传 OpenAI fast(priority)、ultrafast 与 flex 请求;仅作用于 OpenAI 网关。“全部 tier 值”仅包含显式传入的 tier。', empty: '尚未配置任何规则。点击下方按钮新增。', ruleHeader: '规则 #{index}', removeRule: '删除规则', @@ -1102,6 +1102,7 @@ export default { saveHint: '保存时随系统设置一起提交(点击页面底部「保存」按钮)。', serviceTier: 'service_tier 匹配', tierAll: '全部 tier 值', + tierMissing: '省略 tier', tierPriority: 'priority(fast)', tierUltrafast: 'ultrafast', tierFlex: 'flex', diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 234d2c3404..c25b06eb3b 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -1184,6 +1184,7 @@ | 'all' | 'priority' | 'flex' + | 'missing' " :options="openaiFastPolicyTierOptions" /> @@ -12179,6 +12180,7 @@ const openaiFastPolicyTierOptions = computed(() => [ label: t("admin.settings.openaiFastPolicy.tierUltrafast"), }, { value: "flex", label: t("admin.settings.openaiFastPolicy.tierFlex") }, + { value: "missing", label: t("admin.settings.openaiFastPolicy.tierMissing") }, ]); const openaiFastPolicyActionOptions = computed(() => [