feat(openai): OpenAI Fast/Flex Policy 完整实现(HTTP + WebSocket + Admin)
对称参照 Claude BetaPolicy 的 fast-mode 过滤实现,新增针对 OpenAI 上游
service_tier 字段(priority / flex,含客户端 "fast" → "priority" 归一化)的
pass / filter / block 三态策略,覆盖全部 OpenAI 入口 + admin 配置入口。
后端核心
- 新增 SettingKeyOpenAIFastPolicySettings、OpenAIFastPolicyRule、
OpenAIFastPolicySettings 配置模型,含规则的 service_tier × action × scope
× 模型白名单 × fallback action 维度。
- SettingService.Get/SetOpenAIFastPolicySettings;缺失时返回内置默认策略
(所有模型的 priority 走 filter,whitelist 为空,fallback=pass)。设计
依据:service_tier=fast 是用户级开关,与 model 字段正交,默认锁定特定
model slug 会留下"用 gpt-4 + fast 透传 priority 上游"的绕过路径。JSON
解析失败不再静默 fallback,slog.Warn 记录脏数据,便于运维定位。
- service_tier 归一化(trim + ToLower + fast→priority + 白名单 priority/flex)
与策略评估(evaluateOpenAIFastPolicy)作为唯一真实来源,HTTP / WS 共用。
抽出纯函数 evaluateOpenAIFastPolicyWithSettings,配合 ctx-bound settings
快照(withOpenAIFastPolicyContext / openAIFastPolicySettingsFromContext),
WS 长会话入口预取一次后所有帧复用,避免每帧打到 settingService。
HTTP 入口(4 个)
- Chat Completions、Anthropic 兼容(Messages,含 BetaFastMode→priority 二次
命中)、原生 Responses、Passthrough Responses 全部接入
applyOpenAIFastPolicyToBody,filter 走 sjson 顶层删除 service_tier,block
返回 403 forbidden_error JSON。
- 4 入口统一使用 upstream 视角的 model(GetMappedModel +
normalizeOpenAIModelForUpstream + Codex OAuth normalize 后的 slug),
避免 chat/messages/native /responses/passthrough 因为 model 维度不同
造成 whitelist 命中差异。
- 在 pass 路径也把客户端 "fast" 别名归一化为 "priority" 写回 body,
否则 native /responses 与 passthrough 入口会把 "fast" 原样透传给上游
导致 400/拒绝(chat-completions 入口的 normalizeResponsesBodyServiceTier
此前已具备同等行为)。
WebSocket 入口
- 新增 applyOpenAIFastPolicyToWSResponseCreate:严格匹配
type="response.create",仅处理顶层 service_tier;filter 用 sjson 删字段,
block 返回 typed *OpenAIFastBlockedError。
- ingress 路径在 parseClientPayload 内调用,block 命中先 Write Realtime
风格 error event 再返回 OpenAIWSClientCloseError(StatusPolicyViolation
=1008),依赖底层 WebSocket Conn.Write 的同步 flush 保证 error 先于
close。
- passthrough 路径在 RunEntry 前对 firstClientMessage 应用策略,并通过
openAIWSPolicyEnforcingFrameConn 包装 ReadFrame 对每个 client→upstream
帧执行策略;后续帧无 model 字段时回退到 capturedSessionModel。
filter 闭包内同时侦测 session.update / session.created 帧的 session.model
字段刷新 capturedSessionModel,封堵"首帧 model=gpt-4o(pass)→
session.update 改为 gpt-5.5 → 不带 model 的 response.create fallback
到 gpt-4o"的 mid-session 绕过路径。
- passthrough billing:requestServiceTier 在策略 filter 之后再从
firstClientMessage 提取,filter 命中时 OpenAIForwardResult.ServiceTier
上报 nil(default tier),与 HTTP 入口(reqBody 来自 post-filter map)
/ WS ingress(payload 来自 post-filter bytes)的语义一致。
- 错误事件 schema:{event_id: "evt_<32hex>", type: "error",
error: {type: "forbidden_error", code: "policy_violation", message}},
与 OpenAI codex 客户端 error event 解析兼容。
Admin / Frontend
- dto.SystemSettings / UpdateSettingsRequest 新增
openai_fast_policy_settings 字段(omitempty),bulk GET/PUT 接入。
- Settings 页 Gateway 页签新增 Fast/Flex Policy 表单卡片:
service_tier × action × scope × 模型白名单 × fallback action 全字段配置。
- 前端守门:openaiFastPolicyLoaded 标志仅在 GET 真带回字段时才允许回写,
避免 rollout/错误把默认规则覆盖成空;saveSettings 回写循环 skip 该字段,
由专用刷新逻辑处理;仅 action=block 时发送 error_message,匹配后端
omitempty 行为。
测试
- HTTP 路径:openai_fast_policy_test.go 覆盖默认配置(whitelist=[],所有
模型 priority filter)/ block 自定义错误 / scope 区分 / filter 删字段 /
block 不改 body / block 短路上游 / Anthropic BetaFastMode 触发 OpenAI
fast policy 等场景。
- WebSocket 路径:openai_fast_policy_ws_test.go 覆盖
helper 单元(filter / fast→priority 归一化 / flex 透传 / block typed
error / 无 service_tier 字节不变 / 非 response.create 帧不动 / 空 type
帧不动 / event_id+code 字段断言 / 非字符串 service_tier 容错)+
pass 路径 fast 别名归一化回归 +
ingress 端到端(filter 后上游不含 service_tier / block 后客户端先收
error event 再收 close 1008 且上游 0 写)+
passthrough capturedSessionModel fallback 用例(whitelist 策略下首帧
建立、缺 model 命中 fallback、缺少 fallback 时的 leak 文档化)+
passthrough session.update / session.created 旋转 capturedSessionModel
的 mid-session 绕过回归 +
passthrough billing post-filter ServiceTier 与 idempotent filter 回归。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -334,6 +334,7 @@ type OpenAIGatewayService struct {
|
||||
resolver *ModelPricingResolver
|
||||
channelService *ChannelService
|
||||
balanceNotifyService *BalanceNotifyService
|
||||
settingService *SettingService
|
||||
|
||||
openaiWSPoolOnce sync.Once
|
||||
openaiWSStateStoreOnce sync.Once
|
||||
@@ -372,6 +373,7 @@ func NewOpenAIGatewayService(
|
||||
resolver *ModelPricingResolver,
|
||||
channelService *ChannelService,
|
||||
balanceNotifyService *BalanceNotifyService,
|
||||
settingService *SettingService,
|
||||
) *OpenAIGatewayService {
|
||||
svc := &OpenAIGatewayService{
|
||||
accountRepo: accountRepo,
|
||||
@@ -402,6 +404,7 @@ func NewOpenAIGatewayService(
|
||||
resolver: resolver,
|
||||
channelService: channelService,
|
||||
balanceNotifyService: balanceNotifyService,
|
||||
settingService: settingService,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
codexSnapshotThrottle: newAccountWriteThrottle(openAICodexSnapshotPersistMinInterval),
|
||||
}
|
||||
@@ -2310,6 +2313,48 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
disablePatch()
|
||||
}
|
||||
|
||||
// Apply OpenAI fast policy (参照 Claude BetaPolicy 的 fast-mode 过滤):
|
||||
// 针对 body 的 service_tier 字段("priority" 即 fast,"flex"),按策略
|
||||
// 执行 filter(删除字段)或 block(拒绝请求)。对 gpt-5.5 等模型屏蔽
|
||||
// fast 时在此生效。
|
||||
//
|
||||
// 注意:
|
||||
// 1. 此处统一使用 upstreamModel(已经过 GetMappedModel +
|
||||
// normalizeOpenAIModelForUpstream + Codex OAuth normalize),与
|
||||
// chat-completions / messages 入口保持一致,避免不同入口因为模型
|
||||
// 维度不同而出现 whitelist 命中差异。
|
||||
// 2. action=pass 时也要把 raw "fast" 归一化为 "priority" 写回 body,
|
||||
// 否则 native /responses 入口透传 "fast" 给上游会被拒。chat-
|
||||
// completions 入口由 normalizeResponsesBodyServiceTier 完成同一
|
||||
// 行为,这里手工实现等效逻辑。
|
||||
if rawTier, ok := reqBody["service_tier"].(string); ok {
|
||||
if normTier := normalizedOpenAIServiceTierValue(rawTier); normTier != "" {
|
||||
action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, upstreamModel, normTier)
|
||||
switch action {
|
||||
case BetaPolicyActionBlock:
|
||||
msg := errMsg
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, upstreamModel)
|
||||
}
|
||||
blocked := &OpenAIFastBlockedError{Message: msg}
|
||||
writeOpenAIFastPolicyBlockedResponse(c, blocked)
|
||||
return nil, blocked
|
||||
case BetaPolicyActionFilter:
|
||||
delete(reqBody, "service_tier")
|
||||
bodyModified = true
|
||||
disablePatch()
|
||||
default:
|
||||
// pass:若客户端传的是别名 "fast",归一化为 "priority"
|
||||
// 后写回 body,确保上游收到的是其能识别的规范值。
|
||||
if normTier != rawTier {
|
||||
reqBody["service_tier"] = normTier
|
||||
bodyModified = true
|
||||
markPatchSet("service_tier", normTier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-serialize body only if modified
|
||||
if bodyModified {
|
||||
serializedByPatch := false
|
||||
@@ -2758,6 +2803,26 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
body = sanitizedBody
|
||||
}
|
||||
|
||||
// Apply OpenAI fast policy to the passthrough body (filter/block by service_tier).
|
||||
// 统一使用 upstream 视角的 model:透传路径下 body 已经过 compact 映射 +
|
||||
// OAuth normalize,body 中的 model 字段即上游真正会看到的 slug。
|
||||
// 这样可以与 chat-completions / messages / native /responses 入口的
|
||||
// upstreamModel 保持一致,避免 whitelist 命中差异。当 body 中没有
|
||||
// model 字段时退回 reqModel。
|
||||
policyModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
|
||||
if policyModel == "" {
|
||||
policyModel = reqModel
|
||||
}
|
||||
updatedBody, policyErr := s.applyOpenAIFastPolicyToBody(ctx, account, policyModel, body)
|
||||
if policyErr != nil {
|
||||
var blocked *OpenAIFastBlockedError
|
||||
if errors.As(policyErr, &blocked) {
|
||||
writeOpenAIFastPolicyBlockedResponse(c, blocked)
|
||||
}
|
||||
return nil, policyErr
|
||||
}
|
||||
body = updatedBody
|
||||
|
||||
logger.LegacyPrintf("service.openai_gateway",
|
||||
"[OpenAI 自动透传] 命中自动透传分支: account=%d name=%s type=%s model=%s stream=%v",
|
||||
account.ID,
|
||||
@@ -5590,14 +5655,319 @@ func normalizeOpenAIServiceTier(raw string) *string {
|
||||
if value == "fast" {
|
||||
value = "priority"
|
||||
}
|
||||
// 放过 OpenAI 官方文档定义的所有合法 tier 值:priority/flex/auto/default/scale。
|
||||
// 对 Codex 客户端零影响(Codex 只发 priority 或 flex,见 codex-rs/core/src/client.rs),
|
||||
// 但能让直连 OpenAI SDK 的用户透传 auto/default/scale 以便抓包/调试。
|
||||
// 真未知值仍返回 nil,由 normalizeResponsesBodyServiceTier 从 body 中删除。
|
||||
switch value {
|
||||
case "priority", "flex":
|
||||
case "priority", "flex", "auto", "default", "scale":
|
||||
return &value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAIFastBlockedError indicates a request was rejected by the OpenAI fast
|
||||
// policy (action=block). Mirrors BetaBlockedError on the Claude side.
|
||||
type OpenAIFastBlockedError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *OpenAIFastBlockedError) Error() string { return e.Message }
|
||||
|
||||
// evaluateOpenAIFastPolicy returns the action and error message that should be
|
||||
// applied for a request with the given account/model/service_tier. When the
|
||||
// policy service is unavailable or no rule matches, it returns
|
||||
// (BetaPolicyActionPass, "") so callers can short-circuit safely.
|
||||
//
|
||||
// Matching rules:
|
||||
// - Scope filters by account type (all / oauth / apikey / bedrock)
|
||||
// - ServiceTier must be empty (= any), "all", or equal the normalized tier
|
||||
// - ModelWhitelist narrows the rule to specific models; FallbackAction
|
||||
// handles the non-matching case (default: pass)
|
||||
//
|
||||
// 与 Claude BetaPolicy 的差异(保留首条匹配 short-circuit):
|
||||
// - BetaPolicy 处理的是 anthropic-beta header 中的 token 集合,不同
|
||||
// 规则可能针对不同 token,filter 需要累加成 set;block 则 first-match。
|
||||
// - OpenAI fast policy 操作的是单个字段 service_tier:filter 即删字段,
|
||||
// 没有可累加的对象。一次请求只携带一个 service_tier,规则的 tier
|
||||
// 维度天然互斥;同一 (scope, tier) 下若多条规则的 model whitelist
|
||||
// 发生重叠,admin 可通过规则顺序明确意图。因此采用 first-match 而
|
||||
// 非 BetaPolicy 那样的"block 覆盖 filter 覆盖 pass"语义。
|
||||
func (s *OpenAIGatewayService) evaluateOpenAIFastPolicy(ctx context.Context, account *Account, model, serviceTier string) (action, errMsg string) {
|
||||
if s == nil || s.settingService == nil {
|
||||
return BetaPolicyActionPass, ""
|
||||
}
|
||||
tier := strings.ToLower(strings.TrimSpace(serviceTier))
|
||||
if tier == "" {
|
||||
return BetaPolicyActionPass, ""
|
||||
}
|
||||
settings := openAIFastPolicySettingsFromContext(ctx)
|
||||
if settings == nil {
|
||||
fetched, err := s.settingService.GetOpenAIFastPolicySettings(ctx)
|
||||
if err != nil || fetched == nil {
|
||||
return BetaPolicyActionPass, ""
|
||||
}
|
||||
settings = fetched
|
||||
}
|
||||
return evaluateOpenAIFastPolicyWithSettings(settings, account, model, tier)
|
||||
}
|
||||
|
||||
// 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
|
||||
// openAIFastPolicySettingsFromContext for the caching glue.
|
||||
func evaluateOpenAIFastPolicyWithSettings(settings *OpenAIFastPolicySettings, account *Account, model, tier string) (action, errMsg string) {
|
||||
if settings == nil {
|
||||
return BetaPolicyActionPass, ""
|
||||
}
|
||||
isOAuth := account != nil && account.IsOAuth()
|
||||
isBedrock := account != nil && account.IsBedrock()
|
||||
for _, rule := range settings.Rules {
|
||||
if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
|
||||
continue
|
||||
}
|
||||
ruleTier := strings.ToLower(strings.TrimSpace(rule.ServiceTier))
|
||||
if ruleTier != "" && ruleTier != OpenAIFastTierAny && ruleTier != tier {
|
||||
continue
|
||||
}
|
||||
eff := BetaPolicyRule{
|
||||
Action: rule.Action,
|
||||
ErrorMessage: rule.ErrorMessage,
|
||||
ModelWhitelist: rule.ModelWhitelist,
|
||||
FallbackAction: rule.FallbackAction,
|
||||
FallbackErrorMessage: rule.FallbackErrorMessage,
|
||||
}
|
||||
return resolveRuleAction(eff, model)
|
||||
}
|
||||
return BetaPolicyActionPass, ""
|
||||
}
|
||||
|
||||
// openAIFastPolicyCtxKey 是 context 中预取的 OpenAIFastPolicySettings 缓存
|
||||
// 键,仅用于 WebSocket 长会话内多帧复用同一份策略快照,避免每帧 DB 命中。
|
||||
//
|
||||
// Trade-off:策略变更不会影响当前 WS session(只影响新 session)。这是
|
||||
// 有意为之 —— 对长会话来说,"策略一致性"比"立刻生效"更重要,且 Claude
|
||||
// BetaPolicy 的 gin.Context 缓存也是同样取舍。需要 hot-reload 时管理员
|
||||
// 可以通过踢断 session 强制刷新。
|
||||
type openAIFastPolicyCtxKeyType struct{}
|
||||
|
||||
var openAIFastPolicyCtxKey = openAIFastPolicyCtxKeyType{}
|
||||
|
||||
// withOpenAIFastPolicyContext 将一份 settings 快照绑定到 context,供该 ctx
|
||||
// 衍生 goroutine 中的 evaluateOpenAIFastPolicy 复用。
|
||||
func withOpenAIFastPolicyContext(ctx context.Context, settings *OpenAIFastPolicySettings) context.Context {
|
||||
if ctx == nil || settings == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, openAIFastPolicyCtxKey, settings)
|
||||
}
|
||||
|
||||
func openAIFastPolicySettingsFromContext(ctx context.Context) *OpenAIFastPolicySettings {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
if v, ok := ctx.Value(openAIFastPolicyCtxKey).(*OpenAIFastPolicySettings); ok {
|
||||
return v
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyOpenAIFastPolicyToBody applies the OpenAI fast policy to a raw request
|
||||
// body. When action=filter it removes the service_tier field; when
|
||||
// action=block it returns (body, *OpenAIFastBlockedError). On pass it
|
||||
// normalizes the service_tier value (e.g. client alias "fast" → "priority"),
|
||||
// rewriting the body so the upstream receives a slug it recognizes.
|
||||
//
|
||||
// Rationale for normalize-on-pass: chat-completions / messages 入口在调用本
|
||||
// 函数之前已经通过 normalizeResponsesBodyServiceTier 把 service_tier 归一化
|
||||
// 到了上游可识别值;passthrough(OpenAI 自动透传) / native /responses 等
|
||||
// 入口没有这一前置步骤,pass 路径下若不在此处归一化,"fast" 就会被原样
|
||||
// 透传到 OpenAI 上游导致 400/拒绝。把归一化收敛到本函数,所有入口行为一致。
|
||||
func (s *OpenAIGatewayService) applyOpenAIFastPolicyToBody(ctx context.Context, account *Account, model string, body []byte) ([]byte, error) {
|
||||
if len(body) == 0 {
|
||||
return body, nil
|
||||
}
|
||||
rawTier := gjson.GetBytes(body, "service_tier").String()
|
||||
if rawTier == "" {
|
||||
return body, nil
|
||||
}
|
||||
normTier := normalizedOpenAIServiceTierValue(rawTier)
|
||||
if normTier == "" {
|
||||
return body, nil
|
||||
}
|
||||
action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier)
|
||||
switch action {
|
||||
case BetaPolicyActionBlock:
|
||||
msg := errMsg
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model)
|
||||
}
|
||||
return body, &OpenAIFastBlockedError{Message: msg}
|
||||
case BetaPolicyActionFilter:
|
||||
trimmed, err := sjson.DeleteBytes(body, "service_tier")
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("strip service_tier from body: %w", err)
|
||||
}
|
||||
return trimmed, nil
|
||||
default:
|
||||
// pass:把别名(如 "fast")写回为规范值("priority")。
|
||||
if normTier == rawTier {
|
||||
return body, nil
|
||||
}
|
||||
updated, err := sjson.SetBytes(body, "service_tier", normTier)
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("normalize service_tier on pass: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
}
|
||||
|
||||
// writeOpenAIFastPolicyBlockedResponse writes a 403 JSON response for a
|
||||
// request blocked by the OpenAI fast policy.
|
||||
func writeOpenAIFastPolicyBlockedResponse(c *gin.Context, err *OpenAIFastBlockedError) {
|
||||
if c == nil || err == nil {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "permission_error",
|
||||
"message": err.Message,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// applyOpenAIFastPolicyToWSResponseCreate evaluates the OpenAI fast policy
|
||||
// against a single client→upstream WebSocket frame whose top-level
|
||||
// "type"=="response.create". It mirrors the HTTP-side
|
||||
// applyOpenAIFastPolicyToBody contract but operates on a Realtime/Responses
|
||||
// WS payload:
|
||||
//
|
||||
// - pass: returns frame unchanged (newBytes == frame, blocked == nil)
|
||||
// - filter: returns a copy with top-level service_tier removed
|
||||
// - block: returns (frame, *OpenAIFastBlockedError)
|
||||
//
|
||||
// Only frames whose "type" field strictly equals "response.create" are
|
||||
// inspected/mutated. Any other frame type — including the empty string —
|
||||
// passes through untouched. The OpenAI Realtime client-event spec requires
|
||||
// "type" to be set, so an empty type is treated as a malformed frame we do
|
||||
// not police; the upstream is the source of truth for rejecting it.
|
||||
//
|
||||
// service_tier lives at the top level of response.create — same as the
|
||||
// Responses HTTP body shape (see openai_gateway_chat_completions.go:304 +
|
||||
// extractOpenAIServiceTierFromBody at line 5593, and the test fixture at
|
||||
// openai_ws_forwarder_ingress_session_test.go:402). We therefore only need
|
||||
// to inspect / strip the top-level field; there is no nested form in the
|
||||
// schema today.
|
||||
//
|
||||
// The caller is responsible for choosing the upstream model passed in —
|
||||
// this helper does not re-derive it.
|
||||
func (s *OpenAIGatewayService) applyOpenAIFastPolicyToWSResponseCreate(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
model string,
|
||||
frame []byte,
|
||||
) ([]byte, *OpenAIFastBlockedError, error) {
|
||||
if len(frame) == 0 {
|
||||
return frame, nil, nil
|
||||
}
|
||||
if !gjson.ValidBytes(frame) {
|
||||
return frame, nil, nil
|
||||
}
|
||||
frameType := strings.TrimSpace(gjson.GetBytes(frame, "type").String())
|
||||
// Strict match: only response.create is policy-checked. Empty / other
|
||||
// types pass through untouched so we never accidentally strip fields
|
||||
// from response.cancel, conversation.item.create, or any future
|
||||
// client-event the spec adds. The Realtime spec requires "type" on
|
||||
// every client event, so an empty type is malformed input — let the
|
||||
// upstream reject it rather than guessing at our layer.
|
||||
if frameType != "response.create" {
|
||||
return frame, nil, nil
|
||||
}
|
||||
rawTier := gjson.GetBytes(frame, "service_tier").String()
|
||||
if rawTier == "" {
|
||||
return frame, nil, nil
|
||||
}
|
||||
normTier := normalizedOpenAIServiceTierValue(rawTier)
|
||||
if normTier == "" {
|
||||
return frame, nil, nil
|
||||
}
|
||||
action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier)
|
||||
switch action {
|
||||
case BetaPolicyActionBlock:
|
||||
msg := errMsg
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model)
|
||||
}
|
||||
return frame, &OpenAIFastBlockedError{Message: msg}, nil
|
||||
case BetaPolicyActionFilter:
|
||||
trimmed, err := sjson.DeleteBytes(frame, "service_tier")
|
||||
if err != nil {
|
||||
return frame, nil, fmt.Errorf("strip service_tier from ws frame: %w", err)
|
||||
}
|
||||
return trimmed, nil, nil
|
||||
default:
|
||||
return frame, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// newOpenAIFastPolicyWSEventID returns a Realtime-style event_id for a
|
||||
// server-emitted error event. Matches the loose "evt_<rand>" convention used
|
||||
// by upstream Realtime servers; the exact value is not load-bearing and is
|
||||
// only required for client-side log correlation. We reuse the existing
|
||||
// google/uuid dependency rather than pulling a new one.
|
||||
func newOpenAIFastPolicyWSEventID() string {
|
||||
id, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
// Extremely unlikely; fall back to a fixed prefix so the field is
|
||||
// still non-empty and the schema stays self-consistent.
|
||||
return "evt_openai_fast_policy"
|
||||
}
|
||||
// Strip dashes so it visually matches "evt_<hex>" rather than UUID v4
|
||||
// canonical form, mirroring what real Realtime traces look like.
|
||||
return "evt_" + strings.ReplaceAll(id.String(), "-", "")
|
||||
}
|
||||
|
||||
// buildOpenAIFastPolicyBlockedWSEvent renders an OpenAI Realtime/Responses
|
||||
// style "error" event payload for a request blocked by the OpenAI fast
|
||||
// policy. The shape mirrors Realtime error events as observed in upstream
|
||||
// traces and per the spec's server "error" event:
|
||||
//
|
||||
// {
|
||||
// "event_id": "evt_<random>",
|
||||
// "type": "error",
|
||||
// "error": {
|
||||
// "type": "invalid_request_error",
|
||||
// "code": "policy_violation",
|
||||
// "message": "..."
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// event_id lets clients correlate the rejection in their logs; "code" gives
|
||||
// programmatic clients a stable identifier (HTTP-side equivalent is the
|
||||
// 403 permission_error JSON body).
|
||||
func buildOpenAIFastPolicyBlockedWSEvent(err *OpenAIFastBlockedError) []byte {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
eventID := newOpenAIFastPolicyWSEventID()
|
||||
payload, mErr := json.Marshal(map[string]any{
|
||||
"event_id": eventID,
|
||||
"type": "error",
|
||||
"error": map[string]any{
|
||||
"type": "invalid_request_error",
|
||||
"code": "policy_violation",
|
||||
"message": err.Message,
|
||||
},
|
||||
})
|
||||
if mErr != nil {
|
||||
// Fallback to a minimal hand-rolled payload; Marshal of the literal
|
||||
// shape above should never fail in practice.
|
||||
return []byte(`{"event_id":"` + eventID + `","type":"error","error":{"type":"invalid_request_error","code":"policy_violation","message":"openai fast policy blocked this request"}}`)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func sanitizeEmptyBase64InputImagesInOpenAIBody(body []byte) ([]byte, bool, error) {
|
||||
if len(body) == 0 || !bytes.Contains(body, []byte(`"image_url"`)) || !bytes.Contains(body, []byte(`base64,`)) {
|
||||
return body, false, nil
|
||||
|
||||
Reference in New Issue
Block a user