Files
sub2api/backend/internal/service/openai_gateway_request_body.go
T
IrisRainbowNeko 64caa9af8b fix(openai): declare web_search via additional_tools for Responses Lite
Responses Lite rejects hosted tools at the top level ("responses Lite does
not support top-level tool type web_search"), so Lite requests now carry the
injected web_search tool in an input additional_tools item: extend the
caller's first additional_tools item, or insert one before a trailing
compaction_trigger so the v2 compaction wire keeps the trigger last.

The HTTP passthrough path also runs the shared compatibility normalizer on
/responses/compact; thread the compact flag through so that endpoint is
left untouched, and drop the now-redundant call from the passthrough OAuth
body normalizer.
2026-10-09 11:03:13 +08:00

2622 lines
82 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"net/http"
"strings"
"unicode/utf8"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) {
normalized, err := s.validateOutboundURL(raw)
if err != nil {
return "", fmt.Errorf("invalid base_url: %w", err)
}
return normalized, nil
}
// validateOutboundURL 按 security.url_allowlist 策略校验网关主动连接的出站 URL。
func (s *OpenAIGatewayService) validateOutboundURL(raw string) (string, error) {
if s.cfg == nil {
return urlvalidator.ValidateURLFormat(raw, false)
}
if !s.cfg.Security.URLAllowlist.Enabled {
return urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
}
return urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
RequireAllowlist: true,
AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
})
}
// buildOpenAIResponsesURL 组装 OpenAI Responses 端点。
// - base 以 /v1 结尾:追加 /responses
// - base 以其他版本段结尾(如 /v4):追加 /responses
// - base 已是 /responses:原样返回
// - 其他情况:追加 /v1/responses
func buildOpenAIResponsesURL(base string) string {
return buildOpenAIEndpointURL(base, "/v1/responses")
}
// buildOpenAIResponsesURLForPlatform 组装 Responses 端点(平台感知)。
// 供应商 profile 声明了 ResponsesPath 时按其拼接(如 DeepSeek 为无 /v1 前缀的
// /responses);其余平台维持 /v1/responses。
func buildOpenAIResponsesURLForPlatform(platform string, base string) string {
if profile := LookupProviderProfile(platform); profile != nil && profile.ResponsesPath != "" {
return buildOpenAIEndpointURL(base, profile.ResponsesPath)
}
return buildOpenAIResponsesURL(base)
}
func shouldPreserveOpenAIResponsesNoneReasoningEffort(account *Account) bool {
if account == nil {
return false
}
if account.IsOpenAIPassthroughEnabled() {
return true
}
if account.IsOpenAIOAuthLike() {
return true
}
if !account.IsOpenAIApiKey() {
return false
}
baseURL := strings.TrimSpace(account.GetCredential("base_url"))
return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL)
}
// Codex 0.149.0 needs a single advertised effort to directly select a visible
// non-reasoning model. Treat that catalog-only "none" value as omission for
// compatible upstreams, while preserving official OpenAI request semantics.
func filterOpenAIResponsesNoneReasoningEffortForAccount(account *Account, body []byte) ([]byte, error) {
if len(body) == 0 || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) {
return body, nil
}
out := body
for _, path := range []string{"reasoning.effort", "reasoning_effort"} {
effort := gjson.GetBytes(out, path)
if effort.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(effort.String()), "none") {
continue
}
next, err := sjson.DeleteBytes(out, path)
if err != nil {
return body, fmt.Errorf("strip %s none placeholder: %w", path, err)
}
out = next
}
if reasoning := gjson.GetBytes(out, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 {
next, err := sjson.DeleteBytes(out, "reasoning")
if err != nil {
return body, fmt.Errorf("strip empty reasoning object: %w", err)
}
out = next
}
return out, nil
}
func deleteOpenAIResponsesNoneReasoningEffortFromObject(account *Account, body map[string]any) {
if body == nil || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) {
return
}
if effort, ok := body["reasoning_effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") {
delete(body, "reasoning_effort")
}
reasoning, ok := body["reasoning"].(map[string]any)
if !ok {
return
}
if effort, ok := reasoning["effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") {
delete(reasoning, "effort")
}
if len(reasoning) == 0 {
delete(body, "reasoning")
}
}
// normalizeDeepSeekResponsesRequestBody 适配无状态 CN Responses 端点:
// 强制 store=false 并清除 previous_response_id(DeepSeek / Kimi 官方
// Responses 均不支持服务端状态存储,携带这些字段会被拒绝)。
//
// DeepSeek 另需把 Codex/OpenAI 的 input_image.image_url 写成线上 serde
// 要求的 url 字段;openai 平台但 base_url 指向 api.deepseek.com 的映射
// 账号同样走这条出站改写(Codex 贴图会 422 missing field url)。
// 非 CN / 非 DeepSeek 上游原样返回。
func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte {
if account == nil {
return body
}
applyStateless := account.UsesNativeCNResponses()
applyImages := shouldAliasDeepSeekResponsesInputImages(account)
if !applyStateless && !applyImages {
return body
}
normalized := body
if applyStateless {
patched, err := sjson.SetBytes(normalized, "store", false)
if err != nil {
return body
}
normalized = patched
if stripped, err := sjson.DeleteBytes(normalized, "previous_response_id"); err == nil {
normalized = stripped
}
}
var requestBody map[string]any
if err := decodeOpenAIJSONUseNumber(normalized, &requestBody); err != nil {
return normalized
}
input, exists := requestBody["input"]
if !exists {
return normalized
}
changed := false
if liftedInput, lifted := apicompat.LiftResponsesToolOutputMedia(input); lifted {
requestBody["input"] = liftedInput
input = liftedInput
changed = true
}
if applyImages {
if aliased, aliasedChanged := aliasDeepSeekResponsesInputImages(input); aliasedChanged {
requestBody["input"] = aliased
changed = true
}
}
if !changed {
return normalized
}
rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
if err != nil {
return normalized
}
return rebuilt
}
func shouldAliasDeepSeekResponsesInputImages(account *Account) bool {
return targetsDeepSeekAPIHost(account)
}
// aliasDeepSeekResponsesInputImages 把图片 part 改写成 DeepSeek 能反序列化
// 的形状:type=input_image,同时带字符串字段 image_url 与 url。
// 文档与部分 400 提 image_url;线上 serde 要求 url。只发其中一个都会踩坑。
// 仅有 file_id、没有可用 URL 的 part 原样保留。
func aliasDeepSeekResponsesInputImages(input any) (any, bool) {
items, ok := asDeepSeekResponsesSlice(input)
if !ok {
return input, false
}
changed := false
for i, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
continue
}
if aliasDeepSeekResponsesInputItem(item) {
items[i] = item
changed = true
}
}
if !changed {
return input, false
}
return items, true
}
func aliasDeepSeekResponsesInputItem(item map[string]any) bool {
changed := aliasDeepSeekResponsesImagePart(item)
if content, exists := item["content"]; exists {
if rewritten, did := aliasDeepSeekResponsesContent(content); did {
item["content"] = rewritten
changed = true
}
}
if output, exists := item["output"]; exists {
if rewritten, did := aliasDeepSeekResponsesContent(output); did {
item["output"] = rewritten
changed = true
}
}
return changed
}
func aliasDeepSeekResponsesContent(content any) (any, bool) {
parts, ok := asDeepSeekResponsesSlice(content)
if !ok {
return content, false
}
changed := false
for i, raw := range parts {
part, ok := raw.(map[string]any)
if !ok {
continue
}
if aliasDeepSeekResponsesImagePart(part) {
parts[i] = part
changed = true
}
}
if !changed {
return content, false
}
return parts, true
}
func aliasDeepSeekResponsesImagePart(part map[string]any) bool {
partType := strings.TrimSpace(stringValue(part["type"]))
switch partType {
case "input_image", "image_url", "image":
default:
return false
}
imageURL := extractDeepSeekResponsesImageURL(part)
if imageURL == "" {
return false
}
changed := false
if partType != "input_image" {
part["type"] = "input_image"
changed = true
}
if current, ok := part["image_url"].(string); !ok || strings.TrimSpace(current) != imageURL {
part["image_url"] = imageURL
changed = true
}
if current, ok := part["url"].(string); !ok || strings.TrimSpace(current) != imageURL {
part["url"] = imageURL
changed = true
}
return changed
}
func extractDeepSeekResponsesImageURL(part map[string]any) string {
if imageURL := deepSeekResponsesURLValue(part["url"]); imageURL != "" {
return imageURL
}
if imageURL := deepSeekResponsesURLValue(part["image_url"]); imageURL != "" {
return imageURL
}
if imageURL := deepSeekResponsesURLValue(part["image"]); imageURL != "" {
return imageURL
}
source, ok := part["source"].(map[string]any)
if !ok {
return ""
}
if imageURL := deepSeekResponsesURLValue(source["url"]); imageURL != "" {
return imageURL
}
data := strings.TrimSpace(stringValue(source["data"]))
if data == "" {
return ""
}
if strings.HasPrefix(strings.ToLower(data), "data:") {
return data
}
mediaType := strings.TrimSpace(stringValue(source["media_type"]))
if mediaType == "" {
mediaType = "image/png"
}
return "data:" + mediaType + ";base64," + data
}
func deepSeekResponsesURLValue(value any) string {
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case map[string]any:
if imageURL := strings.TrimSpace(stringValue(typed["url"])); imageURL != "" {
return imageURL
}
return strings.TrimSpace(stringValue(typed["image_url"]))
default:
return ""
}
}
func asDeepSeekResponsesSlice(value any) ([]any, bool) {
switch items := value.(type) {
case []any:
return items, true
case []map[string]any:
out := make([]any, len(items))
for i := range items {
out[i] = items[i]
}
return out, true
default:
return nil, false
}
}
func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool {
if len(reqBody) == 0 {
return false
}
inputValue, has := reqBody["input"]
if !has {
return false
}
switch input := inputValue.(type) {
case []any:
filtered := input[:0]
changed := false
for _, item := range input {
nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item)
if itemChanged {
changed = true
}
if !keep {
continue
}
filtered = append(filtered, nextItem)
}
if !changed {
return false
}
if len(filtered) == 0 {
delete(reqBody, "input")
return true
}
reqBody["input"] = filtered
return true
case []map[string]any:
filtered := input[:0]
changed := false
for _, item := range input {
nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item)
if itemChanged {
changed = true
}
if !keep {
continue
}
nextMap, ok := nextItem.(map[string]any)
if !ok {
filtered = append(filtered, item)
continue
}
filtered = append(filtered, nextMap)
}
if !changed {
return false
}
if len(filtered) == 0 {
delete(reqBody, "input")
return true
}
reqBody["input"] = filtered
return true
case map[string]any:
nextItem, changed, keep := sanitizeEncryptedReasoningInputItem(input)
if !changed {
return false
}
if !keep {
delete(reqBody, "input")
return true
}
nextMap, ok := nextItem.(map[string]any)
if !ok {
return false
}
reqBody["input"] = nextMap
return true
default:
return false
}
}
func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep bool) {
inputItem, ok := item.(map[string]any)
if !ok {
return item, false, true
}
itemType, _ := inputItem["type"].(string)
switch strings.TrimSpace(itemType) {
case "compaction", "compaction_summary":
if _, encrypted := inputItem["encrypted_content"]; encrypted {
return nil, true, false
}
return item, false, true
case "reasoning":
default:
return item, false, true
}
if _, has := inputItem["encrypted_content"]; has {
delete(inputItem, "encrypted_content")
changed = true
}
// xAI 422: "content": null 导致 untagged enum 反序列化失败
if v, has := inputItem["content"]; has && v == nil {
delete(inputItem, "content")
changed = true
}
if !changed {
return item, false, true
}
if len(inputItem) == 1 {
return nil, true, false
}
return inputItem, true, true
}
// SanitizeOpenAICrossModeFailoverReasoning derives a failover attempt body from
// the canonical request body by dropping provider-specific encrypted reasoning
// input items in full (encrypted_content plus the coupled id/summary shape).
//
// This is the proactive counterpart to the reactive same-account
// invalid_encrypted_content recovery in Forward: when a failover switches from an
// OpenAI passthrough account (which forwards upstream-native encrypted reasoning,
// e.g. Kiro) to a non-passthrough account (e.g. Bedrock Mantle) that rejects the
// provider-specific reasoning IDs/shape, the whole reasoning item must go before
// the request reaches the new upstream. Unlike trimOpenAIEncryptedReasoningItems,
// which only strips the encrypted_content / null-content fields while preserving
// the reasoning item's id and summary, this drops the entire item.
//
// The input slice is treated as immutable and is never mutated; a distinct slice
// is returned only when changed is true.
func SanitizeOpenAICrossModeFailoverReasoning(body []byte) (sanitized []byte, changed bool, err error) {
if len(body) == 0 {
return body, false, nil
}
if !gjson.GetBytes(body, "input").Exists() {
return body, false, nil
}
var decoded map[string]any
if err := decodeOpenAIJSONUseNumber(body, &decoded); err != nil {
return body, false, fmt.Errorf("decode cross-mode failover body: %w", err)
}
if !dropOpenAIEncryptedReasoningInputItems(decoded) {
return body, false, nil
}
out, marshalErr := marshalOpenAIUpstreamJSON(decoded)
if marshalErr != nil {
return body, false, fmt.Errorf("serialize cross-mode failover body: %w", marshalErr)
}
return out, true, nil
}
// dropOpenAIEncryptedReasoningInputItems removes reasoning input items that carry
// provider-specific encrypted_content in full — including their coupled id and
// summary — and reports whether anything changed. Contrast with
// trimOpenAIEncryptedReasoningItems, which only strips fields while keeping the
// reasoning item skeleton.
func dropOpenAIEncryptedReasoningInputItems(reqBody map[string]any) bool {
if len(reqBody) == 0 {
return false
}
inputValue, has := reqBody["input"]
if !has {
return false
}
switch input := inputValue.(type) {
case []any:
filtered := input[:0]
changed := false
for _, item := range input {
if isOpenAIEncryptedReasoningInputItem(item) {
changed = true
continue
}
filtered = append(filtered, item)
}
if !changed {
return false
}
if len(filtered) == 0 {
delete(reqBody, "input")
return true
}
reqBody["input"] = filtered
return true
case []map[string]any:
filtered := input[:0]
changed := false
for _, item := range input {
if isOpenAIEncryptedReasoningInputItem(item) {
changed = true
continue
}
filtered = append(filtered, item)
}
if !changed {
return false
}
if len(filtered) == 0 {
delete(reqBody, "input")
return true
}
reqBody["input"] = filtered
return true
case map[string]any:
if isOpenAIEncryptedReasoningInputItem(input) {
delete(reqBody, "input")
return true
}
return false
default:
return false
}
}
func isOpenAIEncryptedReasoningInputItem(item any) bool {
inputItem, ok := item.(map[string]any)
if !ok {
return false
}
if itemType, _ := inputItem["type"].(string); strings.TrimSpace(itemType) != "reasoning" {
return false
}
_, has := inputItem["encrypted_content"]
return has
}
// IsOpenAIResponsesCompactPath reports whether the request targets the legacy
// /responses/compact endpoint, including its forwardable subpaths.
func IsOpenAIResponsesCompactPath(c *gin.Context) bool {
return isOpenAIResponsesCompactPath(c)
}
func OpenAICompactSessionSeedKeyForTest() string {
return openAICompactSessionSeedKey
}
func NormalizeOpenAICompactRequestBodyForTest(body []byte) ([]byte, bool, error) {
return normalizeOpenAICompactRequestBody(body)
}
func isOpenAIResponsesCompactPath(c *gin.Context) bool {
suffix := strings.TrimSpace(openAIResponsesRequestPathSuffix(c))
return suffix == "/compact" || strings.HasPrefix(suffix, "/compact/")
}
func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
normalized := []byte(`{}`)
// Keep the current Codex /compact schema while still dropping request-scoped
// fields such as prompt_cache_key, store, and stream.
for _, field := range []string{
"model",
"input",
"instructions",
"tools",
"parallel_tool_calls",
"reasoning",
"service_tier",
"text",
"previous_response_id",
} {
value := gjson.GetBytes(body, field)
if !value.Exists() {
continue
}
next, err := sjson.SetRawBytes(normalized, field, []byte(value.Raw))
if err != nil {
return body, false, fmt.Errorf("normalize compact body %s: %w", field, err)
}
normalized = next
}
if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, false); err != nil {
return body, false, err
} else if removed {
normalized = next
}
if bytes.Equal(bytes.TrimSpace(body), bytes.TrimSpace(normalized)) {
return body, false, nil
}
return normalized, true, nil
}
func normalizeOpenAIParallelToolCallsWithoutTools(body []byte, responsesLite bool) ([]byte, bool, error) {
if responsesLite {
return body, false, nil
}
parallel := gjson.GetBytes(body, "parallel_tool_calls")
if !parallel.Exists() {
return body, false, nil
}
if openAIRequestBodyHasTools(body) {
return body, false, nil
}
normalized, err := sjson.DeleteBytes(body, "parallel_tool_calls")
if err != nil {
return body, false, fmt.Errorf("normalize parallel_tool_calls without tools: %w", err)
}
return normalized, true, nil
}
// openAIRequestBodyHasTools 同时识别顶层 tools 和 input[].additional_tools。
func openAIRequestBodyHasTools(body []byte) bool {
if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 {
return true
}
for _, item := range gjson.GetBytes(body, "input").Array() {
if strings.TrimSpace(item.Get("type").String()) != "additional_tools" {
continue
}
if tools := item.Get("tools"); tools.IsArray() && len(tools.Array()) > 0 {
return true
}
}
return false
}
// normalizeOpenAIResponsesReasoningContentReplay removes non-portable
// reasoning.content arrays before history is sent to a real OpenAI Responses
// endpoint. Compatible providers may return visible reasoning blocks there,
// while OpenAI accepts only an empty array when the item is replayed.
//
// Keep the reasoning item and its portable fields (summary, encrypted_content,
// ids, and opaque extensions). Callers scope this normalization to OpenAI
// destinations; compatible providers may still consume their own content.
func normalizeOpenAIResponsesReasoningContentReplay(body []byte) ([]byte, bool, error) {
input := parseRawJSONView(body).Get("input")
if !input.IsArray() {
return body, false, nil
}
needsNormalization := false
input.ForEach(func(_, item gjson.Result) bool {
if strings.TrimSpace(item.Get("type").String()) != "reasoning" {
return true
}
content := item.Get("content")
if content.IsArray() && len(content.Array()) > 0 {
needsNormalization = true
return false
}
return true
})
if !needsNormalization {
return body, false, nil
}
var reqBody map[string]any
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
return body, false, fmt.Errorf("normalize OpenAI reasoning content replay: %w", err)
}
items, ok := reqBody["input"].([]any)
if !ok {
return body, false, nil
}
changed := false
for _, rawItem := range items {
item, ok := rawItem.(map[string]any)
if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "reasoning" {
continue
}
content, ok := item["content"].([]any)
if !ok || len(content) == 0 {
continue
}
delete(item, "content")
changed = true
}
if !changed {
return body, false, nil
}
normalized, err := marshalOpenAIUpstreamJSON(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize normalized OpenAI reasoning content replay: %w", err)
}
return normalized, true, nil
}
func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) {
if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False {
return body, false, nil
}
root := parseRawJSONView(body)
input := root.Get("input")
if !input.IsArray() {
return body, false, nil
}
if !root.IsObject() || !gjson.ValidBytes(body) || !utf8.Valid(body) || hasDuplicateJSONObjectKeys(root) {
return normalizeOpenAIAPIKeyStoreFalseReasoningReplayDecoded(body, knownStoreFalse)
}
// Only reasoning metadata needs decoding. Keep large image/tool results as
// slices of the original JSON and copy them once into the final request.
items := make([]string, 0)
changed := false
fallback := false
var itemErr error
input.ForEach(func(_, item gjson.Result) bool {
if !item.IsObject() {
items = append(items, item.Raw)
return true
}
if hasDuplicateJSONObjectKeys(item) {
fallback = true
return false
}
typ := strings.TrimSpace(item.Get("type").String())
id := strings.TrimSpace(item.Get("id").String())
encrypted := item.Get("encrypted_content")
if (typ == "reasoning" && (encrypted.Type != gjson.String || strings.TrimSpace(encrypted.Str) == "")) ||
(typ == "item_reference" && strings.HasPrefix(id, "rs_")) {
changed = true
return true
}
stripID := typ == "reasoning" && strings.HasPrefix(id, "rs_")
addSummary := typ == "reasoning" && item.Get("summary").Type == gjson.Null
stripCallID := shouldStripOpenAIResponsesNonPairCallID(typ) && item.Get("call_id").Exists()
if !stripID && !addSummary && !stripCallID {
items = append(items, item.Raw)
return true
}
var decoded map[string]any
if err := decodeOpenAIJSONUseNumber([]byte(item.Raw), &decoded); err != nil {
itemErr = err
return false
}
if stripID {
delete(decoded, "id")
}
if addSummary {
decoded["summary"] = []any{}
}
if stripCallID {
delete(decoded, "call_id")
}
encoded, err := marshalOpenAIUpstreamJSON(decoded)
if err != nil {
itemErr = err
return false
}
items = append(items, string(encoded))
changed = true
return true
})
if fallback {
return normalizeOpenAIAPIKeyStoreFalseReasoningReplayDecoded(body, knownStoreFalse)
}
if itemErr != nil {
return body, false, fmt.Errorf("normalize API-key store=false reasoning replay: %w", itemErr)
}
if !changed {
return body, false, nil
}
return replaceOpenAIRawInput(body, input, items), true, nil
}
// Preserve the decoder's handling of unusual or duplicate-key input objects.
func normalizeOpenAIAPIKeyStoreFalseReasoningReplayDecoded(body []byte, knownStoreFalse bool) ([]byte, bool, error) {
if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False {
return body, false, nil
}
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body, false, nil
}
var reqBody map[string]any
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
return body, false, fmt.Errorf("normalize API-key store=false reasoning replay: %w", err)
}
items, ok := reqBody["input"].([]any)
if !ok {
return body, false, nil
}
filtered := make([]any, 0, len(items))
changed := false
for _, rawItem := range items {
item, ok := rawItem.(map[string]any)
if !ok {
filtered = append(filtered, rawItem)
continue
}
typ := strings.TrimSpace(firstNonEmptyString(item["type"]))
id := strings.TrimSpace(firstNonEmptyString(item["id"]))
switch typ {
case "reasoning":
encryptedContent, hasEncryptedContent := item["encrypted_content"].(string)
if !hasEncryptedContent || strings.TrimSpace(encryptedContent) == "" {
changed = true
continue
}
if strings.HasPrefix(id, "rs_") {
delete(item, "id")
changed = true
}
if summary, ok := item["summary"]; !ok || summary == nil {
item["summary"] = []any{}
changed = true
}
case "item_reference":
if strings.HasPrefix(id, "rs_") {
changed = true
continue
}
}
if shouldStripOpenAIResponsesNonPairCallID(typ) {
if _, hasCallID := item["call_id"]; hasCallID {
delete(item, "call_id")
changed = true
}
}
filtered = append(filtered, item)
}
if !changed {
return body, false, nil
}
reqBody["input"] = filtered
normalized, err := marshalOpenAIUpstreamJSON(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize API-key store=false reasoning replay: %w", err)
}
return normalized, true, nil
}
func normalizeOpenAICodexCompactReasoningEffortForAccount(c *gin.Context, account *Account, body []byte) ([]byte, bool, error) {
if account == nil || !account.IsOpenAIOAuthLike() || !isOpenAIResponsesCompactPath(c) {
return body, false, nil
}
requestedModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
effectiveModel := account.GetMappedModel(requestedModel)
return normalizeOpenAICodexCompactReasoningEffort(body, effectiveModel)
}
func normalizeOpenAICodexCompactReasoningEffort(body []byte, effectiveModel string) ([]byte, bool, error) {
if !isOpenAIGPT56Model(effectiveModel) ||
!strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()), "max") {
return body, false, nil
}
// Codex Ultra 在客户端编排层会下发 max;ChatGPT compact 端点目前只接受到
// xhigh。这里只降级 OpenAI OAuth 的 GPT-5.6 compact 子请求,普通 Responses、
// API Key 请求和其他平台的 OAuth 请求保留 max。
normalized, err := sjson.SetBytes(body, "reasoning.effort", "xhigh")
if err != nil {
return body, false, fmt.Errorf("normalize codex compact reasoning effort: %w", err)
}
return normalized, true, nil
}
func resolveOpenAICompactSessionID(c *gin.Context) string {
if c != nil {
if sessionID := strings.TrimSpace(c.GetHeader("session_id")); sessionID != "" {
return sessionID
}
if conversationID := strings.TrimSpace(c.GetHeader("conversation_id")); conversationID != "" {
return conversationID
}
if seed, ok := c.Get(openAICompactSessionSeedKey); ok {
if seedStr, ok := seed.(string); ok && strings.TrimSpace(seedStr) != "" {
return strings.TrimSpace(seedStr)
}
}
}
return uuid.NewString()
}
// openAIResponsesRequestPathSuffix 返回可拼接到上游 /responses URL 后面的子路径。
// 不可转发的子路径返回空串(退化为裸 /responses);真正的拒绝由入口守卫
// IsForwardableOpenAIResponsesRequestPath 负责。这样即便将来新增路由漏挂守卫,
// 拼进上游 URL 的也只会是合规片段。
func openAIResponsesRequestPathSuffix(c *gin.Context) string {
suffix, ok := sanitizedUpstreamPathSuffix(rawOpenAIResponsesRequestPathSuffix(c))
if !ok {
return ""
}
return suffix
}
// IsForwardableOpenAIResponsesRequestPath 判断入站请求携带的 /responses 子路径
// 是否可以安全转发。路由层用它在鉴权后、调度前直接拒绝畸形子路径。
func IsForwardableOpenAIResponsesRequestPath(c *gin.Context) bool {
_, ok := sanitizedUpstreamPathSuffix(rawOpenAIResponsesRequestPathSuffix(c))
return ok
}
// IsOpenAIResponsesInputTokensRequestPath reports whether the request targets
// the native Responses input-token counting endpoint.
func IsOpenAIResponsesInputTokensRequestPath(c *gin.Context) bool {
return openAIResponsesRequestPathSuffix(c) == "/input_tokens"
}
// rawOpenAIResponsesRequestPathSuffix 仅做提取,不做任何安全判断。
func rawOpenAIResponsesRequestPathSuffix(c *gin.Context) string {
if c == nil || c.Request == nil || c.Request.URL == nil {
return ""
}
normalizedPath := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/")
if normalizedPath == "" {
return ""
}
idx := strings.LastIndex(normalizedPath, "/responses")
if idx < 0 {
return ""
}
suffix := normalizedPath[idx+len("/responses"):]
if suffix == "" || suffix == "/" {
return ""
}
if !strings.HasPrefix(suffix, "/") {
return ""
}
return suffix
}
func appendOpenAIResponsesRequestPathSuffix(baseURL, suffix string) string {
trimmedBase := strings.TrimRight(strings.TrimSpace(baseURL), "/")
// 兜底:调用方漏了校验时,这里也不会把不合规的片段拼进上游 URL。
trimmedSuffix, ok := sanitizedUpstreamPathSuffix(suffix)
if !ok || trimmedBase == "" || trimmedSuffix == "" {
return trimmedBase
}
return trimmedBase + trimmedSuffix
}
func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte {
// A mapped request must retain its public name even when upstream uses an alias.
if fromModel == "" || toModel == "" || fromModel == toModel || !gjson.ValidBytes(body) {
return body
}
if m := gjson.GetBytes(body, "model"); m.Type == gjson.String {
newBody, err := sjson.SetBytes(body, "model", toModel)
if err != nil {
return body
}
return newBody
}
return body
}
func getOpenAIReasoningEffortFromReqBody(reqBody map[string]any, requestedModel string) (value string, present bool) {
if reqBody == nil {
return "", false
}
// Primary: reasoning.effort
if reasoning, ok := reqBody["reasoning"].(map[string]any); ok {
if effort, ok := reasoning["effort"].(string); ok {
return normalizeOpenAIReasoningEffortForModel(effort, requestedModel), true
}
}
// Fallback: some clients may use a flat field.
if effort, ok := reqBody["reasoning_effort"].(string); ok {
return normalizeOpenAIReasoningEffortForModel(effort, requestedModel), true
}
return "", false
}
func deriveOpenAIReasoningEffortFromModel(model string) string {
if strings.TrimSpace(model) == "" {
return ""
}
modelID := strings.TrimSpace(model)
if strings.Contains(modelID, "/") {
parts := strings.Split(modelID, "/")
modelID = parts[len(parts)-1]
}
parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool {
switch r {
case '-', '_', ' ':
return true
default:
return false
}
})
if len(parts) == 0 {
return ""
}
return normalizeOpenAIReasoningEffortForModel(parts[len(parts)-1], modelID)
}
// deriveOpenAIReasoningEffortFromModelCandidates 依次对每个候选模型做后缀推导,
// 返回第一个非空结果。
func deriveOpenAIReasoningEffortFromModelCandidates(models []string) string {
for _, model := range models {
if value := deriveOpenAIReasoningEffortFromModel(model); value != "" {
return value
}
}
return ""
}
type openAIRequestView struct {
body []byte
Model string
Stream bool
PromptCacheKey string
PreviousResponseID string
ServiceTier string
ReasoningEffort string
patches []openAIRequestPatch
patchesDisabled bool
}
type openAIRequestPatch struct {
path string
delete bool
value any
}
func newOpenAIRequestView(body []byte) openAIRequestView {
if len(body) == 0 {
return openAIRequestView{}
}
const (
modelField uint8 = 1 << iota
streamField
promptCacheKeyField
previousResponseIDField
serviceTierField
reasoningField
allRequestViewFields = modelField | streamField | promptCacheKeyField |
previousResponseIDField | serviceTierField | reasoningField
)
view := openAIRequestView{body: body}
var seen uint8
// parseRawJSONView reads body without copying; view keeps body alive for extracted strings.
parseRawJSONView(body).ForEach(func(key, value gjson.Result) bool {
switch key.Str {
case "model":
if seen&modelField == 0 {
view.Model = strings.TrimSpace(value.String())
seen |= modelField
}
case "stream":
if seen&streamField == 0 {
view.Stream = value.Bool()
seen |= streamField
}
case "prompt_cache_key":
if seen&promptCacheKeyField == 0 {
view.PromptCacheKey = strings.TrimSpace(value.String())
seen |= promptCacheKeyField
}
case "previous_response_id":
if seen&previousResponseIDField == 0 {
view.PreviousResponseID = strings.TrimSpace(value.String())
seen |= previousResponseIDField
}
case "service_tier":
if seen&serviceTierField == 0 {
view.ServiceTier = strings.TrimSpace(value.String())
seen |= serviceTierField
}
case "reasoning":
if seen&reasoningField == 0 {
view.ReasoningEffort = strings.TrimSpace(value.Get("effort").String())
seen |= reasoningField
}
}
return seen != allRequestViewFields
})
return view
}
// Decode 保留阶段一既有 full-map 行为;后续阶段会把调用点下沉到复杂分支。
func (v openAIRequestView) Decode(c *gin.Context) (map[string]any, error) {
return getOpenAIRequestBodyMap(c, v.body)
}
func (v *openAIRequestView) MarkPatchSet(path string, value any) {
if v == nil || v.patchesDisabled {
return
}
path = strings.TrimSpace(path)
if !isSimpleOpenAIRequestPatchPath(path) {
v.DisablePatches()
return
}
v.patches = append(v.patches, openAIRequestPatch{path: path, value: value})
}
func (v *openAIRequestView) MarkPatchDelete(path string) {
if v == nil || v.patchesDisabled {
return
}
path = strings.TrimSpace(path)
if !isSimpleOpenAIRequestPatchPath(path) {
v.DisablePatches()
return
}
v.patches = append(v.patches, openAIRequestPatch{path: path, delete: true})
}
func isSimpleOpenAIRequestPatchPath(path string) bool {
if path == "" || strings.ContainsRune(path, '\\') {
return false
}
for _, part := range strings.Split(path, ".") {
if strings.TrimSpace(part) == "" {
return false
}
}
return true
}
func (v *openAIRequestView) DisablePatches() {
if v == nil {
return
}
v.patchesDisabled = true
v.patches = nil
}
func (v openAIRequestView) HasPatches() bool {
return !v.patchesDisabled && len(v.patches) > 0
}
func (v openAIRequestView) ApplyPatches() ([]byte, error) {
if v.patchesDisabled || len(v.patches) == 0 {
return nil, errors.New("openai request patches disabled")
}
body := v.body
for _, patch := range v.patches {
var err error
if patch.delete {
body, err = sjson.DeleteBytes(body, patch.path)
} else {
body, err = sjson.SetBytes(body, patch.path, patch.value)
}
if err != nil {
return nil, err
}
}
return body, nil
}
func setOpenAIRequestMapPath(reqBody map[string]any, path string, value any) {
path = strings.TrimSpace(path)
if reqBody == nil || path == "" {
return
}
parts := strings.Split(path, ".")
current := reqBody
for _, part := range parts[:len(parts)-1] {
part = strings.TrimSpace(part)
if part == "" {
return
}
next, _ := current[part].(map[string]any)
if next == nil {
next = map[string]any{}
current[part] = next
}
current = next
}
last := strings.TrimSpace(parts[len(parts)-1])
if last != "" {
current[last] = value
}
}
func deleteOpenAIRequestMapPath(reqBody map[string]any, path string) {
path = strings.TrimSpace(path)
if reqBody == nil || path == "" {
return
}
parts := strings.Split(path, ".")
current := reqBody
for _, part := range parts[:len(parts)-1] {
part = strings.TrimSpace(part)
if part == "" {
return
}
next, _ := current[part].(map[string]any)
if next == nil {
return
}
current = next
}
last := strings.TrimSpace(parts[len(parts)-1])
if last != "" {
delete(current, last)
}
}
func extractOpenAIRequestMetaFromBody(body []byte) (model string, stream bool, promptCacheKey string) {
view := newOpenAIRequestView(body)
return view.Model, view.Stream, view.PromptCacheKey
}
func normalizeOpenAIOAuthResponsesCompatibilityFields(reqBody map[string]any) bool {
if reqBody == nil {
return false
}
changed := false
if prompt, exists := reqBody["prompt"]; exists {
if input, hasInput := reqBody["input"]; !hasInput || input == nil {
if prompt != nil {
reqBody["input"] = prompt
}
}
delete(reqBody, "prompt")
changed = true
}
if _, exists := reqBody["commands"]; exists {
delete(reqBody, "commands")
changed = true
}
// Codex can attach internal message metadata when a custom provider is
// named OpenAI. ChatGPT rejects this field on input items (#7066).
input, _ := reqBody["input"].([]any)
for _, value := range input {
item, ok := value.(map[string]any)
if !ok {
continue
}
if _, exists := item["internal_chat_message_metadata_passthrough"]; exists {
delete(item, "internal_chat_message_metadata_passthrough")
changed = true
}
}
return changed
}
func normalizeOpenAIOAuthResponsesCompatibilityBody(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
normalized := body
changed := false
prompt := gjson.GetBytes(normalized, "prompt")
if prompt.Exists() {
input := gjson.GetBytes(normalized, "input")
if prompt.Type != gjson.Null && (!input.Exists() || input.Type == gjson.Null) {
next, err := sjson.SetRawBytes(normalized, "input", []byte(prompt.Raw))
if err != nil {
return body, false, fmt.Errorf("normalize oauth responses prompt: %w", err)
}
normalized = next
}
next, err := sjson.DeleteBytes(normalized, "prompt")
if err != nil {
return body, false, fmt.Errorf("normalize oauth responses delete prompt: %w", err)
}
normalized = next
changed = true
}
if gjson.GetBytes(normalized, "commands").Exists() {
next, err := sjson.DeleteBytes(normalized, "commands")
if err != nil {
return body, false, fmt.Errorf("normalize oauth responses delete commands: %w", err)
}
normalized = next
changed = true
}
// Only remove the input-item field, never same-named user content.
input := gjson.GetBytes(normalized, "input")
if !input.IsArray() {
return normalized, changed, nil
}
for i, item := range input.Array() {
if !item.IsObject() || !item.Get("internal_chat_message_metadata_passthrough").Exists() {
continue
}
next, err := sjson.DeleteBytes(normalized, fmt.Sprintf("input.%d.internal_chat_message_metadata_passthrough", i))
if err != nil {
return body, false, fmt.Errorf("normalize oauth input metadata: %w", err)
}
normalized = next
changed = true
}
return normalized, changed, nil
}
func normalizeGPT6ResponsesSampling(body []byte, model string) ([]byte, bool, error) {
if (!openai.IsGPT6SolOrLunaModelSpelling(model) && !openai.IsGPT61SolModelSpelling(model)) || gjson.GetBytes(body, "reasoning.effort").String() == "none" {
return body, false, nil
}
out := body
changed := false
for _, key := range []string{"temperature", "top_p", "top_logprobs", "logprobs"} {
if !gjson.GetBytes(out, key).Exists() {
continue
}
var err error
out, err = sjson.DeleteBytes(out, key)
if err != nil {
return body, false, fmt.Errorf("remove GPT-6 sampling parameter %s: %w", key, err)
}
changed = true
}
if include := gjson.GetBytes(out, "include"); include.IsArray() {
items := include.Array()
for i := len(items) - 1; i >= 0; i-- {
if items[i].String() != "message.output_text.logprobs" {
continue
}
var err error
out, err = sjson.DeleteBytes(out, fmt.Sprintf("include.%d", i))
if err != nil {
return body, false, fmt.Errorf("remove GPT-6 logprobs include: %w", err)
}
changed = true
}
}
return out, changed, nil
}
func normalizeOpenAIResponsesReasoningMode(body []byte, model string) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
// GPT-6 treats reasoning.mode and reasoning.effort as independent
// official fields. Preserve both verbatim; earlier models retain the
// established mode stripping and pro-to-max compatibility behavior.
if model == "" {
model = gjson.GetBytes(body, "model").String()
}
if isOpenAIGPT6Model(model) {
return normalizeGPT6ResponsesSampling(body, model)
}
mode := gjson.GetBytes(body, "reasoning.mode")
if !mode.Exists() || mode.Type != gjson.String {
return body, false, nil
}
updated := body
effort := gjson.GetBytes(body, "reasoning.effort")
if (!effort.Exists() || effort.Type == gjson.Null || strings.TrimSpace(effort.String()) == "") &&
strings.EqualFold(strings.TrimSpace(mode.String()), "pro") {
var err error
updated, err = sjson.SetBytes(updated, "reasoning.effort", "max")
if err != nil {
return body, false, fmt.Errorf("set reasoning effort for mode=pro: %w", err)
}
}
updated, err := sjson.DeleteBytes(updated, "reasoning.mode")
if err != nil {
return body, false, fmt.Errorf("delete unsupported reasoning.mode: %w", err)
}
if reasoning := gjson.GetBytes(updated, "reasoning"); reasoning.Exists() && reasoning.IsObject() && len(reasoning.Map()) == 0 {
updated, err = sjson.DeleteBytes(updated, "reasoning")
if err != nil {
return body, false, fmt.Errorf("delete empty reasoning object: %w", err)
}
}
return updated, true, nil
}
func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
textFormat := strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String())
responseFormat := strings.TrimSpace(gjson.GetBytes(body, "response_format.type").String())
if textFormat != "json_schema" && responseFormat != "json_schema" {
return body, false, nil
}
var reqBody map[string]any
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
return body, false, fmt.Errorf("normalize responses schema body: %w", err)
}
if !normalizeOpenAIResponseFormatSchemas(reqBody) {
return body, false, nil
}
normalized, err := json.Marshal(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize normalized responses schema body: %w", err)
}
return normalized, true, nil
}
func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account, responsesLite bool) ([]byte, bool, error) {
return normalizeOpenAIResponsesCompatibilityBodyWithOptions(body, account, openAIResponsesCompatibilityOptions{ResponsesLite: responsesLite})
}
type openAIResponsesCompatibilityOptions struct {
ResponsesLite bool
// Compact marks the /responses/compact wire shape, which is left as-is
// by request-shape compatibility rewrites such as web_search history.
Compact bool
}
func normalizeOpenAIResponsesCompatibilityBodyWithOptions(body []byte, account *Account, opts openAIResponsesCompatibilityOptions) ([]byte, bool, error) {
responsesLite := opts.ResponsesLite
if account == nil || !account.IsOpenAI() {
return body, false, nil
}
normalized := body
changed := false
if account.IsOpenAIOAuthLike() {
var err error
normalized, changed, err = normalizeOpenAIResponsesLegacyIngress(body)
if err != nil {
return body, false, err
}
}
if next, normalizedReasoningContent, err := normalizeOpenAIResponsesReasoningContentReplay(normalized); err != nil {
return body, false, err
} else if normalizedReasoningContent {
normalized = next
changed = true
}
if account.IsOpenAIApiKey() {
if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, responsesLite); err != nil {
return body, false, err
} else if normalizedParallel {
normalized = next
changed = true
}
if next, normalizedReasoning, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(normalized, false); err != nil {
return body, false, err
} else if normalizedReasoning {
normalized = next
changed = true
}
}
if sanitized, idsChanged, err := sanitizeOpenAIResponsesInputItemIDs(normalized); err != nil {
return body, false, fmt.Errorf("sanitize websocket Responses input item IDs: %w", err)
} else if idsChanged {
normalized = sanitized
changed = true
}
if account != nil && account.IsOpenAI() && account.IsOAuth() {
if reasoningBody, reasoningChanged, err := normalizeOpenAIResponsesReasoningMode(normalized, account.GetMappedModel(gjson.GetBytes(normalized, "model").String())); err != nil {
return body, false, err
} else if reasoningChanged {
normalized = reasoningBody
changed = true
}
}
if account != nil && account.IsOpenAIOAuthLike() {
oauthBody, oauthChanged, err := normalizeOpenAIOAuthResponsesCompatibilityBody(normalized)
if err != nil {
return body, false, err
}
normalized = oauthBody
changed = changed || oauthChanged
for _, field := range openAIChatGPTInternalUnsupportedFields {
if !gjson.GetBytes(normalized, field).Exists() {
continue
}
next, deleteErr := sjson.DeleteBytes(normalized, field)
if deleteErr != nil {
return body, false, fmt.Errorf("normalize websocket body delete %s: %w", field, deleteErr)
}
normalized = next
changed = true
}
if !opts.Compact {
webSearchBody, webSearchChanged, err := ensureOpenAIOAuthWebSearchToolForHistoryBody(normalized, responsesLite)
if err != nil {
return body, false, fmt.Errorf("normalize websocket body: %w", err)
}
normalized = webSearchBody
changed = changed || webSearchChanged
}
}
needsOrphanCleanup := account != nil && account.IsOpenAIOAuthLike() &&
gjson.GetBytes(normalized, "input").IsArray()
if needsOrphanCleanup || openAIResponsesInputMayNeedTruncation(normalized) {
var reqBody map[string]any
if err := decodeOpenAIJSONUseNumber(normalized, &reqBody); err != nil {
return body, false, fmt.Errorf("normalize websocket Responses body: %w", err)
}
mapChanged := false
if needsOrphanCleanup {
if input, ok := reqBody["input"].([]any); ok && sanitizeOpenAIResponsesOrphanToolOutputs(
reqBody,
input,
strings.TrimSpace(firstNonEmptyString(reqBody["previous_response_id"])) != "",
) {
mapChanged = true
}
}
if truncateOpenAIResponsesInputText(reqBody) {
mapChanged = true
}
if mapChanged {
next, err := marshalOpenAIUpstreamJSON(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize normalized websocket Responses body: %w", err)
}
normalized = next
changed = true
}
}
if schemaBody, schemaChanged, err := normalizeOpenAIResponseFormatSchemasBody(normalized); err != nil {
return body, false, err
} else if schemaChanged {
normalized = schemaBody
changed = true
}
if openAIRequestBodyImageGenerationToolNeedsNormalization(normalized) {
var reqBody map[string]any
if err := json.Unmarshal(normalized, &reqBody); err != nil {
return body, false, fmt.Errorf("normalize websocket image tool body: %w", err)
}
if normalizeOpenAIResponsesImageGenerationTools(reqBody) {
next, err := json.Marshal(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize normalized websocket image tool body: %w", err)
}
normalized = next
changed = true
}
}
if account != nil {
if schemaBody, schemaChanged, err := sanitizeOpenAIResponsesToolSchemasForPlatform(normalized, account.Platform); err != nil {
return body, false, fmt.Errorf("normalize websocket tool schemas: %w", err)
} else if schemaChanged {
normalized = schemaBody
changed = true
}
}
// Keep this last: earlier compatibility passes may filter or rebuild input.
// Remote compaction v2 requires one trigger as the final input item.
if triggerBody, triggerChanged, err := NormalizeCompactionTriggerInputOrder(normalized); err != nil {
return body, false, fmt.Errorf("normalize websocket compaction trigger order: %w", err)
} else if triggerChanged {
normalized = triggerBody
changed = true
}
return normalized, changed, nil
}
// normalizeOpenAIPassthroughOAuthBody 将透传 OAuth 请求体收敛为旧链路关键行为:
// 1) 删除 ChatGPT internal API 不支持的顶层 Responses 参数
// 2) store=false 3) 非 compact 保持 stream=true;compact 强制 stream=false
func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, bool, error) {
if len(body) == 0 {
return body, false, nil
}
normalized, changed, err := normalizeOpenAIOAuthResponsesCompatibilityBody(body)
if err != nil {
return body, false, err
}
if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized, ""); reasoningErr != nil {
return body, false, reasoningErr
} else if reasoningChanged {
normalized = reasoningBody
changed = true
}
for _, field := range openAIChatGPTInternalUnsupportedFields {
if value := gjson.GetBytes(normalized, field); !value.Exists() {
continue
}
next, err := sjson.DeleteBytes(normalized, field)
if err != nil {
return body, false, fmt.Errorf("normalize passthrough body delete %s: %w", field, err)
}
normalized = next
changed = true
}
if schemaBody, schemaChanged, schemaErr := normalizeOpenAIResponseFormatSchemasBody(normalized); schemaErr != nil {
return body, false, schemaErr
} else if schemaChanged {
normalized = schemaBody
changed = true
}
if inputResult := gjson.GetBytes(normalized, "input"); inputResult.Exists() {
switch {
case inputResult.Type == gjson.String:
text := inputResult.String()
var inputValue any
if strings.TrimSpace(text) != "" {
inputValue = []any{map[string]any{
"type": "message", "role": "user", "content": text,
}}
} else {
inputValue = []any{}
}
next, err := sjson.SetBytes(normalized, "input", inputValue)
if err != nil {
return body, false, fmt.Errorf("normalize passthrough body input string: %w", err)
}
normalized = next
changed = true
case inputResult.Type == gjson.JSON && !inputResult.IsArray():
next, err := sjson.SetRawBytes(normalized, "input", []byte("["+inputResult.Raw+"]"))
if err != nil {
return body, false, fmt.Errorf("normalize passthrough body input object: %w", err)
}
normalized = next
changed = true
}
}
if compact {
if store := gjson.GetBytes(normalized, "store"); store.Exists() {
next, err := sjson.DeleteBytes(normalized, "store")
if err != nil {
return body, false, fmt.Errorf("normalize passthrough body delete store: %w", err)
}
normalized = next
changed = true
}
if stream := gjson.GetBytes(normalized, "stream"); stream.Exists() {
next, err := sjson.DeleteBytes(normalized, "stream")
if err != nil {
return body, false, fmt.Errorf("normalize passthrough body delete stream: %w", err)
}
normalized = next
changed = true
}
} else {
if store := gjson.GetBytes(normalized, "store"); !store.Exists() || store.Type != gjson.False {
next, err := sjson.SetBytes(normalized, "store", false)
if err != nil {
return body, false, fmt.Errorf("normalize passthrough body store=false: %w", err)
}
normalized = next
changed = true
}
if stream := gjson.GetBytes(normalized, "stream"); !stream.Exists() || stream.Type != gjson.True {
next, err := sjson.SetBytes(normalized, "stream", true)
if err != nil {
return body, false, fmt.Errorf("normalize passthrough body stream=true: %w", err)
}
normalized = next
changed = true
}
}
return normalized, changed, nil
}
func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string {
if !isOpenAICodexModel(reqModel) {
return ""
}
instructions := gjson.GetBytes(body, "instructions")
if !instructions.Exists() {
return ""
}
if instructions.Type != gjson.String {
return "instructions_not_string"
}
if strings.TrimSpace(instructions.String()) == "" {
return "instructions_empty"
}
return ""
}
func isOpenAICodexModel(model string) bool {
return strings.Contains(strings.ToLower(strings.TrimSpace(model)), "codex")
}
// extractOpenAIReasoningEffortFromBody 按优先级传入模型候选(如 upstreamModel,
// billingModel, originalModel):显式 effort 的模型归一化(max 保留判定)用第一个
// 非空候选;body 未携带 effort 时的模型后缀推导依次尝试每个候选——OAuth 的
// normalizeCodexModel 会剥掉 upstreamModel 的 effort 后缀,只有原始模型名还留着。
func extractOpenAIReasoningEffortFromBody(body []byte, modelCandidates ...string) *string {
reasoningEffort := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String())
if reasoningEffort == "" {
reasoningEffort = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String())
}
if reasoningEffort != "" {
normalized := normalizeOpenAIReasoningEffortForModel(reasoningEffort, firstNonEmpty(modelCandidates...))
if normalized == "" {
return nil
}
return &normalized
}
value := deriveOpenAIReasoningEffortFromModelCandidates(modelCandidates)
if value == "" {
return nil
}
return &value
}
func explicitRequestedReasoningEffortFromBody(body []byte) string {
raw := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String())
if raw == "" {
raw = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String())
}
if raw == "" {
raw = strings.TrimSpace(gjson.GetBytes(body, "output_config.effort").String())
}
return raw
}
// CanonicalRequestedReasoningEffort extracts the client-requested effort before
// group policy rewriting and before model-family remapping (max -> xhigh).
// Empty or unknown values return nil. "max" is preserved even for models that
// later persist "xhigh".
func CanonicalRequestedReasoningEffort(body []byte, modelCandidates ...string) *string {
if raw := explicitRequestedReasoningEffortFromBody(body); raw != "" {
canonical := NormalizeMaxReasoningEffort(raw)
if canonical == "" {
return nil
}
return &canonical
}
for _, model := range modelCandidates {
if value := canonicalReasoningEffortFromModelSuffix(model); value != "" {
return &value
}
}
if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model != "" {
if value := canonicalReasoningEffortFromModelSuffix(model); value != "" {
return &value
}
}
return nil
}
func canonicalReasoningEffortFromModelSuffix(model string) string {
if strings.TrimSpace(model) == "" {
return ""
}
modelID := strings.TrimSpace(model)
if strings.Contains(modelID, "/") {
parts := strings.Split(modelID, "/")
modelID = parts[len(parts)-1]
}
parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool {
switch r {
case '-', '_', ' ':
return true
default:
return false
}
})
if len(parts) == 0 {
return ""
}
return NormalizeMaxReasoningEffort(parts[len(parts)-1])
}
func extractOpenAIServiceTier(reqBody map[string]any) *string {
if reqBody == nil {
return nil
}
raw, ok := reqBody["service_tier"].(string)
if !ok {
return nil
}
return normalizeOpenAIServiceTier(raw)
}
func extractOpenAIServiceTierFromBody(body []byte) *string {
if len(body) == 0 {
return nil
}
return normalizeOpenAIServiceTier(gjson.GetBytes(body, "service_tier").String())
}
func normalizeOpenAIServiceTier(raw string) *string {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return nil
}
if value == "fast" {
value = "priority"
}
// 放过 OpenAI 官方文档定义的合法 tier 值,以及 Codex/API 新增的 ultrafast。
// Codex 客户端会发 priority、flex 或 ultrafast;直连 OpenAI SDK 的用户还会
// 透传 auto/default/scale。真未知值仍返回 nil,由
// normalizeResponsesBodyServiceTier 从 body 中删除。
switch value {
case "priority", "flex", "auto", "default", "scale", OpenAIFastTierUltrafast:
return &value
default:
return nil
}
}
// ErrInvalidOpenAIServiceTier indicates a request carried a service_tier value
// that is not a known OpenAI tier. HTTP handlers translate it into a 400
// invalid_request_error so malformed values are rejected up front instead of
// being silently stripped (which would mask the user's intent to use fast
// mode).
type ErrInvalidOpenAIServiceTier struct {
Value string
}
func (e *ErrInvalidOpenAIServiceTier) Error() string {
return fmt.Sprintf("invalid service_tier %q: must be one of auto, default, fast, flex, priority, scale, ultrafast", e.Value)
}
const invalidOpenAIServiceTierValueMaxLen = 64
func boundInvalidOpenAIServiceTierValue(raw string) string {
if len(raw) <= invalidOpenAIServiceTierValueMaxLen {
return raw
}
return raw[:invalidOpenAIServiceTierValueMaxLen] + "..."
}
// ValidateOpenAIServiceTierField validates the service_tier field of a raw
// OpenAI-compatible request body (/v1/responses and /v1/chat/completions).
//
// - absent / null → valid, returns "" (field omitted keeps current behavior)
// - "fast" → normalized to "priority" (the two are equivalent; the canonical
// value is what reaches the OpenAI upstream)
// - "priority" / "flex" / "auto" / "default" / "scale" / "ultrafast" → valid, returned as-is
// - an explicitly present non-string value, an empty string, or any other
// unknown value → *ErrInvalidOpenAIServiceTier (handler maps to HTTP 400),
// matching OpenAI's enum validation semantics
func ValidateOpenAIServiceTierField(body []byte) (string, error) {
tierResult := gjson.GetBytes(body, "service_tier")
if !tierResult.Exists() || tierResult.Type == gjson.Null {
return "", nil
}
if tierResult.Type != gjson.String {
return "", &ErrInvalidOpenAIServiceTier{Value: "<non-string>"}
}
raw := strings.TrimSpace(tierResult.String())
if raw == "" {
return "", &ErrInvalidOpenAIServiceTier{Value: raw}
}
norm := normalizedOpenAIServiceTierValue(raw)
if norm == "" {
return "", &ErrInvalidOpenAIServiceTier{Value: boundInvalidOpenAIServiceTierValue(raw)}
}
return norm, 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)
// - UserIDs, when present, filters by the trusted Sub2API user that owns the API key
// - 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
// the configured first-match order
//
// 与 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, 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
// openAIFastPolicySettingsFromContext for the caching glue.
func evaluateOpenAIFastPolicyWithSettings(settings *OpenAIFastPolicySettings, userID int64, account *Account, model, tier string) (action, errMsg string) {
if settings == nil {
return BetaPolicyActionPass, ""
}
isOAuth := account != nil && account.IsOAuth()
isBedrock := account != nil && account.IsBedrock()
// 用户专属规则先于全局规则。规则组内仍按配置顺序首条命中,允许
// 管理员为某位用户配置例外,而不被先出现的全局规则覆盖。
for _, userScoped := range []bool{true, false} {
for _, rule := range settings.Rules {
if (len(rule.UserIDs) > 0) != userScoped || !openAIFastPolicyUserMatches(rule.UserIDs, userID) {
continue
}
if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
continue
}
ruleTier := strings.ToLower(strings.TrimSpace(rule.ServiceTier))
if tier == OpenAIFastTierMissing {
if ruleTier != OpenAIFastTierMissing {
continue
}
} else 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, ""
}
func openAIFastPolicyUserID(ctx context.Context) int64 {
if ctx == nil {
return 0
}
userID, _ := ctx.Value(ctxkey.UserID).(int64)
if userID <= 0 {
return 0
}
return userID
}
func openAIFastPolicyUserMatches(ruleUserIDs []int64, userID int64) bool {
if len(ruleUserIDs) == 0 {
return true
}
for _, ruleUserID := range ruleUserIDs {
if ruleUserID == userID {
return true
}
}
return false
}
// 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
}
func openAIGroupForcesFast(ctx context.Context, account *Account) bool {
if ctx == nil || account == nil || account.Platform != PlatformOpenAI {
return false
}
group, _ := ctx.Value(ctxkey.Group).(*Group)
return IsGroupContextValid(group) && groupSupportsOpenAIFast(group.Platform) && group.ForceOpenAIFast
}
// 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").
// action=force_priority rewrites any matched known tier to "priority". Before
// the global policy is evaluated, a trusted request Group with
// ForceOpenAIFast enabled unconditionally sets service_tier to "priority".
// The global policy remains authoritative and may still pass, filter, or block
// that final value.
//
// 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
}
if openAIGroupForcesFast(ctx, account) {
updated, err := sjson.SetBytes(body, "service_tier", OpenAIFastTierPriority)
if err != nil {
return body, fmt.Errorf("force group service_tier priority on body: %w", err)
}
body = updated
}
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)
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
case OpenAIFastPolicyActionForcePriority:
updated, err := sjson.SetBytes(body, "service_tier", OpenAIFastTierPriority)
if err != nil {
return body, fmt.Errorf("force service_tier priority on body: %w", err)
}
return updated, 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
}
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
// body-signal compact 心跳可能已把响应头提交为 200(长排队后才进入
// Forward),此时以 response.failed 终止事件回传;未提交时先停拍再写
// JSON,保持原状态码语义(#3887)。
if StopOpenAICompactSSEKeepaliveCommitted(c) {
writeOpenAICompactSSEFailureMessage(c, http.StatusForbidden, "permission_error", err.Message)
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: keeps service_tier, normalizing aliases such as "fast" to "priority"
// - filter: returns a copy with top-level service_tier removed
// - force_priority: keeps service_tier and rewrites it to "priority"
// - block: returns (frame, *OpenAIFastBlockedError)
// - Group ForceOpenAIFast: sets priority first, then applies the global rule
//
// 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
}
if openAIGroupForcesFast(ctx, account) {
updated, err := sjson.SetBytes(frame, "service_tier", OpenAIFastTierPriority)
if err != nil {
return frame, nil, fmt.Errorf("force group service_tier priority in ws frame: %w", err)
}
frame = updated
}
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)
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
case OpenAIFastPolicyActionForcePriority:
updated, err := sjson.SetBytes(frame, "service_tier", OpenAIFastTierPriority)
if err != nil {
return frame, nil, fmt.Errorf("force service_tier priority in ws frame: %w", err)
}
return updated, nil, nil
default:
if normTier == rawTier {
return frame, nil, nil
}
updated, err := sjson.SetBytes(frame, "service_tier", normTier)
if err != nil {
return frame, nil, fmt.Errorf("normalize service_tier in ws frame: %w", err)
}
return updated, 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 openAIRequestBodyMayContainImageInput(body []byte) bool {
if len(body) == 0 {
return false
}
input := gjson.GetBytes(body, "input")
messages := gjson.GetBytes(body, "messages.#-1")
return openAIJSONValueMayContainImageInput(input) || openAIJSONValueMayContainImageInput(messages)
}
func openAIJSONValueMayContainImageInput(value gjson.Result) bool {
if !value.Exists() {
return false
}
if value.IsArray() {
found := false
value.ForEach(func(_, item gjson.Result) bool {
if openAIJSONValueMayContainImageInput(item) {
found = true
return false
}
return true
})
return found
}
if value.IsObject() {
if strings.TrimSpace(value.Get("type").String()) == "input_image" || value.Get("image_url").Exists() {
return true
}
return openAIJSONValueMayContainImageInput(value.Get("content"))
}
return false
}
func openAIRequestBodyMayContainEmptyBase64InputImage(body []byte) bool {
if len(body) == 0 || !openAIRequestBodyMayContainInputImageToken(body) {
return false
}
input := gjson.GetBytes(body, "input")
if !input.Exists() {
return false
}
return openAIJSONValueMayContainEmptyBase64InputImage(input)
}
func openAIRequestBodyMayContainInputImageToken(body []byte) bool {
if bytes.Contains(body, []byte("input_image")) {
return true
}
// JSON 字符串任意字符都可能被 unicode escape,遇到 \u 时交给 gjson 解码后的结构扫描兜底。
return bytes.Contains(body, []byte("\\u"))
}
func openAIJSONValueMayContainEmptyBase64InputImage(value gjson.Result) bool {
if !value.Exists() {
return false
}
if value.IsArray() {
found := false
value.ForEach(func(_, item gjson.Result) bool {
if openAIJSONValueMayContainEmptyBase64InputImage(item) {
found = true
return false
}
return true
})
return found
}
if value.IsObject() {
if strings.TrimSpace(value.Get("type").String()) == "input_image" && isEmptyBase64DataURI(value.Get("image_url").String()) {
return true
}
return openAIJSONValueMayContainEmptyBase64InputImage(value.Get("content"))
}
return false
}
func sanitizeEmptyBase64InputImagesInOpenAIBody(body []byte) ([]byte, bool, error) {
if !openAIRequestBodyMayContainEmptyBase64InputImage(body) {
return body, false, nil
}
var reqBody map[string]any
if err := json.Unmarshal(body, &reqBody); err != nil {
return body, false, fmt.Errorf("sanitize request body: %w", err)
}
if !sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody) {
return body, false, nil
}
normalized, err := marshalOpenAIUpstreamJSON(reqBody)
if err != nil {
return body, false, fmt.Errorf("serialize sanitized request body: %w", err)
}
return normalized, true, nil
}
func sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody map[string]any) bool {
if reqBody == nil {
return false
}
input, ok := reqBody["input"]
if !ok {
return false
}
normalizedInput, changed := sanitizeEmptyBase64InputImagesInOpenAIInput(input)
if !changed {
return false
}
reqBody["input"] = normalizedInput
return true
}
func sanitizeEmptyBase64InputImagesInOpenAIInput(input any) (any, bool) {
items, ok := input.([]any)
if !ok {
return input, false
}
normalizedItems := make([]any, 0, len(items))
changed := false
for _, item := range items {
itemMap, ok := item.(map[string]any)
if !ok {
normalizedItems = append(normalizedItems, item)
continue
}
if shouldDropEmptyBase64InputImagePart(itemMap) {
changed = true
continue
}
content, ok := itemMap["content"]
if !ok {
normalizedItems = append(normalizedItems, itemMap)
continue
}
parts, ok := content.([]any)
if !ok {
normalizedItems = append(normalizedItems, itemMap)
continue
}
normalizedParts := make([]any, 0, len(parts))
itemChanged := false
for _, part := range parts {
if shouldDropEmptyBase64InputImagePart(part) {
changed = true
itemChanged = true
continue
}
normalizedParts = append(normalizedParts, part)
}
if itemChanged {
if len(normalizedParts) == 0 {
continue
}
itemMap["content"] = normalizedParts
}
normalizedItems = append(normalizedItems, itemMap)
}
if !changed {
return input, false
}
return normalizedItems, true
}
func shouldDropEmptyBase64InputImagePart(part any) bool {
partMap, ok := part.(map[string]any)
if !ok {
return false
}
typeValue, _ := partMap["type"].(string)
if strings.TrimSpace(typeValue) != "input_image" {
return false
}
imageURL, _ := partMap["image_url"].(string)
return isEmptyBase64DataURI(imageURL)
}
func isEmptyBase64DataURI(raw string) bool {
if !strings.HasPrefix(raw, "data:") {
return false
}
rest := strings.TrimPrefix(raw, "data:")
semicolonIdx := strings.Index(rest, ";")
if semicolonIdx < 0 {
return false
}
rest = rest[semicolonIdx+1:]
if !strings.HasPrefix(rest, "base64,") {
return false
}
return strings.TrimSpace(strings.TrimPrefix(rest, "base64,")) == ""
}
func getOpenAIRequestBodyMap(_ *gin.Context, body []byte) (map[string]any, error) {
var reqBody map[string]any
if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
return nil, fmt.Errorf("parse request: %w", err)
}
return reqBody, nil
}
// extractOpenAIReasoningEffort 的模型候选语义同 extractOpenAIReasoningEffortFromBody。
func extractOpenAIReasoningEffort(reqBody map[string]any, modelCandidates ...string) *string {
if value, present := getOpenAIReasoningEffortFromReqBody(reqBody, firstNonEmpty(modelCandidates...)); present {
if value == "" {
return nil
}
return &value
}
value := deriveOpenAIReasoningEffortFromModelCandidates(modelCandidates)
if value == "" {
return nil
}
return &value
}
func CanonicalRequestedReasoningEffortFromReqBody(reqBody map[string]any, modelCandidates ...string) *string {
if reqBody == nil {
return CanonicalRequestedReasoningEffort(nil, modelCandidates...)
}
raw := ""
if reasoning, ok := reqBody["reasoning"].(map[string]any); ok {
if effort, ok := reasoning["effort"].(string); ok {
raw = strings.TrimSpace(effort)
}
}
if raw == "" {
if effort, ok := reqBody["reasoning_effort"].(string); ok {
raw = strings.TrimSpace(effort)
}
}
if raw != "" {
canonical := NormalizeMaxReasoningEffort(raw)
if canonical == "" {
return nil
}
return &canonical
}
return CanonicalRequestedReasoningEffort(nil, modelCandidates...)
}
func normalizeOpenAIReasoningEffort(raw string) string {
value := strings.ToLower(strings.TrimSpace(raw))
if value == "" {
return ""
}
// Normalize separators for "x-high"/"x_high" variants.
value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value)
switch value {
case "none", "minimal", "low", "medium", "high":
return value
case "xhigh", "extrahigh", "max":
return "xhigh"
default:
// Only store known effort levels for now to keep UI consistent.
return ""
}
}
func normalizeOpenAIReasoningEffortForModel(raw, model string) string {
if strings.EqualFold(strings.TrimSpace(raw), "none") && openai.IsGPT6SolOrLunaModelSpelling(model) {
return "none"
}
if strings.EqualFold(strings.TrimSpace(raw), "max") && supportsOpenAIReasoningEffortMax(model) {
return "max"
}
return normalizeOpenAIReasoningEffort(raw)
}
// supportsOpenAIReasoningEffortMax reports model families whose upstream scale
// has a distinct max level. Other models keep the legacy max -> xhigh behavior.
func supportsOpenAIReasoningEffortMax(model string) bool {
if isOpenAIGPT6Model(model) || isOpenAIGPT56Model(model) {
return true
}
normalized := strings.ToLower(lastOpenAIModelSegment(model))
normalized = strings.ReplaceAll(normalized, "_", "-")
switch {
case strings.HasPrefix(normalized, "deepseek-v4"), strings.HasPrefix(normalized, "deepseek-flash"):
// deepseek-flash(= DeepSeek-V4.1-Flash)与 v4 系同为 low/high/max 档位。
return true
case strings.HasPrefix(normalized, "glm-"):
return true
case strings.HasPrefix(normalized, "kimi-"), strings.HasPrefix(normalized, "moonshot-"):
return true
case normalized == "k3" || strings.HasPrefix(normalized, "k3-"):
return true
default:
return false
}
}
// validateGPT61SolCompatRequest runs after model mapping, before conversion can
// discard unsupported explicit effort selections or disabled thinking.
func validateGPT61SolCompatRequest(body []byte, model string) error {
if !openai.IsGPT61SolModelSpelling(model) {
return nil
}
for _, path := range []string{"reasoning.effort", "reasoning_effort", "output_config.effort"} {
if err := openai.ValidateGPT61SolReasoningEffort(model, gjson.GetBytes(body, path).String()); err != nil {
return err
}
}
if gjson.GetBytes(body, "thinking.type").String() == "disabled" {
return openai.ValidateGPT61SolReasoningEffort(model, "none")
}
requestedModel := gjson.GetBytes(body, "model").String()
for _, effort := range []string{"none", "minimal"} {
if strings.HasSuffix(strings.ToLower(requestedModel), "-"+effort) {
return openai.ValidateGPT61SolReasoningEffort(model, effort)
}
}
return nil
}