| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "strings"
- "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 端点(平台感知)。
- // DeepSeek 官方 Responses 端点为 /responses(无 /v1 前缀,适配 Codex);
- // 其余平台维持 /v1/responses。
- func buildOpenAIResponsesURLForPlatform(platform string, base string) string {
- if platform == PlatformDeepseek {
- return buildOpenAIEndpointURL(base, "/responses")
- }
- 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 均不支持服务端状态存储,携带这些字段会被拒绝)。
- // 非原生 Responses 协议账号原样返回。
- func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte {
- if account == nil || !account.UsesNativeCNResponses() {
- return body
- }
- normalized, err := sjson.SetBytes(body, "store", false)
- if err != nil {
- return body
- }
- if stripped, err := sjson.DeleteBytes(normalized, "previous_response_id"); err == nil {
- normalized = stripped
- }
- return normalized
- }
- 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 := gjson.GetBytes(body, "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
- }
- 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 {
- // 使用 gjson/sjson 精确替换 model 字段,避免全量 JSON 反序列化
- if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel {
- 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
- }
- 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
- }
- return normalized, changed, nil
- }
- func normalizeOpenAIResponsesReasoningMode(body []byte) ([]byte, bool, error) {
- if len(body) == 0 {
- return body, false, nil
- }
- // Astra 的 reasoning.mode 与 reasoning.effort 是独立参数,不做兼容替换;非 Astra 维持旧 strip-mode/pro->max 行为。
- if isOpenAIGPT6AstraModel(gjson.GetBytes(body, "model").String()) {
- return body, false, nil
- }
- 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) {
- 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); 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
- }
- }
- 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", 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)
- }
- // 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 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 == "" {
- 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 == "" {
- 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":
- return ""
- case "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), "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 isOpenAIGPT6AstraModel(model) || isOpenAIGPT56Model(model) {
- return true
- }
- normalized := strings.ToLower(lastOpenAIModelSegment(model))
- normalized = strings.ReplaceAll(normalized, "_", "-")
- switch {
- case strings.HasPrefix(normalized, "deepseek-v4"):
- 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
- }
- }
|