mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 15:43:13 +08:00
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.
2622 lines
82 KiB
Go
2622 lines
82 KiB
Go
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
|
||
}
|