openai_gateway_request_body.go 68 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "net/http"
  9. "strings"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  11. "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
  12. "github.com/gin-gonic/gin"
  13. "github.com/google/uuid"
  14. "github.com/tidwall/gjson"
  15. "github.com/tidwall/sjson"
  16. )
  17. func (s *OpenAIGatewayService) validateUpstreamBaseURL(raw string) (string, error) {
  18. normalized, err := s.validateOutboundURL(raw)
  19. if err != nil {
  20. return "", fmt.Errorf("invalid base_url: %w", err)
  21. }
  22. return normalized, nil
  23. }
  24. // validateOutboundURL 按 security.url_allowlist 策略校验网关主动连接的出站 URL。
  25. func (s *OpenAIGatewayService) validateOutboundURL(raw string) (string, error) {
  26. if s.cfg == nil {
  27. return urlvalidator.ValidateURLFormat(raw, false)
  28. }
  29. if !s.cfg.Security.URLAllowlist.Enabled {
  30. return urlvalidator.ValidateURLFormat(raw, s.cfg.Security.URLAllowlist.AllowInsecureHTTP)
  31. }
  32. return urlvalidator.ValidateHTTPSURL(raw, urlvalidator.ValidationOptions{
  33. AllowedHosts: s.cfg.Security.URLAllowlist.UpstreamHosts,
  34. RequireAllowlist: true,
  35. AllowPrivate: s.cfg.Security.URLAllowlist.AllowPrivateHosts,
  36. })
  37. }
  38. // buildOpenAIResponsesURL 组装 OpenAI Responses 端点。
  39. // - base 以 /v1 结尾:追加 /responses
  40. // - base 以其他版本段结尾(如 /v4):追加 /responses
  41. // - base 已是 /responses:原样返回
  42. // - 其他情况:追加 /v1/responses
  43. func buildOpenAIResponsesURL(base string) string {
  44. return buildOpenAIEndpointURL(base, "/v1/responses")
  45. }
  46. // buildOpenAIResponsesURLForPlatform 组装 Responses 端点(平台感知)。
  47. // DeepSeek 官方 Responses 端点为 /responses(无 /v1 前缀,适配 Codex);
  48. // 其余平台维持 /v1/responses。
  49. func buildOpenAIResponsesURLForPlatform(platform string, base string) string {
  50. if platform == PlatformDeepseek {
  51. return buildOpenAIEndpointURL(base, "/responses")
  52. }
  53. return buildOpenAIResponsesURL(base)
  54. }
  55. func shouldPreserveOpenAIResponsesNoneReasoningEffort(account *Account) bool {
  56. if account == nil {
  57. return false
  58. }
  59. if account.IsOpenAIPassthroughEnabled() {
  60. return true
  61. }
  62. if account.IsOpenAIOAuthLike() {
  63. return true
  64. }
  65. if !account.IsOpenAIApiKey() {
  66. return false
  67. }
  68. baseURL := strings.TrimSpace(account.GetCredential("base_url"))
  69. return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL)
  70. }
  71. // Codex 0.149.0 needs a single advertised effort to directly select a visible
  72. // non-reasoning model. Treat that catalog-only "none" value as omission for
  73. // compatible upstreams, while preserving official OpenAI request semantics.
  74. func filterOpenAIResponsesNoneReasoningEffortForAccount(account *Account, body []byte) ([]byte, error) {
  75. if len(body) == 0 || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) {
  76. return body, nil
  77. }
  78. out := body
  79. for _, path := range []string{"reasoning.effort", "reasoning_effort"} {
  80. effort := gjson.GetBytes(out, path)
  81. if effort.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(effort.String()), "none") {
  82. continue
  83. }
  84. next, err := sjson.DeleteBytes(out, path)
  85. if err != nil {
  86. return body, fmt.Errorf("strip %s none placeholder: %w", path, err)
  87. }
  88. out = next
  89. }
  90. if reasoning := gjson.GetBytes(out, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 {
  91. next, err := sjson.DeleteBytes(out, "reasoning")
  92. if err != nil {
  93. return body, fmt.Errorf("strip empty reasoning object: %w", err)
  94. }
  95. out = next
  96. }
  97. return out, nil
  98. }
  99. func deleteOpenAIResponsesNoneReasoningEffortFromObject(account *Account, body map[string]any) {
  100. if body == nil || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) {
  101. return
  102. }
  103. if effort, ok := body["reasoning_effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") {
  104. delete(body, "reasoning_effort")
  105. }
  106. reasoning, ok := body["reasoning"].(map[string]any)
  107. if !ok {
  108. return
  109. }
  110. if effort, ok := reasoning["effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") {
  111. delete(reasoning, "effort")
  112. }
  113. if len(reasoning) == 0 {
  114. delete(body, "reasoning")
  115. }
  116. }
  117. // normalizeDeepSeekResponsesRequestBody 适配无状态 CN Responses 端点:
  118. // 强制 store=false 并清除 previous_response_id(DeepSeek / Kimi 官方
  119. // Responses 均不支持服务端状态存储,携带这些字段会被拒绝)。
  120. // 非原生 Responses 协议账号原样返回。
  121. func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte {
  122. if account == nil || !account.UsesNativeCNResponses() {
  123. return body
  124. }
  125. normalized, err := sjson.SetBytes(body, "store", false)
  126. if err != nil {
  127. return body
  128. }
  129. if stripped, err := sjson.DeleteBytes(normalized, "previous_response_id"); err == nil {
  130. normalized = stripped
  131. }
  132. return normalized
  133. }
  134. func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool {
  135. if len(reqBody) == 0 {
  136. return false
  137. }
  138. inputValue, has := reqBody["input"]
  139. if !has {
  140. return false
  141. }
  142. switch input := inputValue.(type) {
  143. case []any:
  144. filtered := input[:0]
  145. changed := false
  146. for _, item := range input {
  147. nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item)
  148. if itemChanged {
  149. changed = true
  150. }
  151. if !keep {
  152. continue
  153. }
  154. filtered = append(filtered, nextItem)
  155. }
  156. if !changed {
  157. return false
  158. }
  159. if len(filtered) == 0 {
  160. delete(reqBody, "input")
  161. return true
  162. }
  163. reqBody["input"] = filtered
  164. return true
  165. case []map[string]any:
  166. filtered := input[:0]
  167. changed := false
  168. for _, item := range input {
  169. nextItem, itemChanged, keep := sanitizeEncryptedReasoningInputItem(item)
  170. if itemChanged {
  171. changed = true
  172. }
  173. if !keep {
  174. continue
  175. }
  176. nextMap, ok := nextItem.(map[string]any)
  177. if !ok {
  178. filtered = append(filtered, item)
  179. continue
  180. }
  181. filtered = append(filtered, nextMap)
  182. }
  183. if !changed {
  184. return false
  185. }
  186. if len(filtered) == 0 {
  187. delete(reqBody, "input")
  188. return true
  189. }
  190. reqBody["input"] = filtered
  191. return true
  192. case map[string]any:
  193. nextItem, changed, keep := sanitizeEncryptedReasoningInputItem(input)
  194. if !changed {
  195. return false
  196. }
  197. if !keep {
  198. delete(reqBody, "input")
  199. return true
  200. }
  201. nextMap, ok := nextItem.(map[string]any)
  202. if !ok {
  203. return false
  204. }
  205. reqBody["input"] = nextMap
  206. return true
  207. default:
  208. return false
  209. }
  210. }
  211. func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep bool) {
  212. inputItem, ok := item.(map[string]any)
  213. if !ok {
  214. return item, false, true
  215. }
  216. itemType, _ := inputItem["type"].(string)
  217. switch strings.TrimSpace(itemType) {
  218. case "compaction", "compaction_summary":
  219. if _, encrypted := inputItem["encrypted_content"]; encrypted {
  220. return nil, true, false
  221. }
  222. return item, false, true
  223. case "reasoning":
  224. default:
  225. return item, false, true
  226. }
  227. if _, has := inputItem["encrypted_content"]; has {
  228. delete(inputItem, "encrypted_content")
  229. changed = true
  230. }
  231. // xAI 422: "content": null 导致 untagged enum 反序列化失败
  232. if v, has := inputItem["content"]; has && v == nil {
  233. delete(inputItem, "content")
  234. changed = true
  235. }
  236. if !changed {
  237. return item, false, true
  238. }
  239. if len(inputItem) == 1 {
  240. return nil, true, false
  241. }
  242. return inputItem, true, true
  243. }
  244. // SanitizeOpenAICrossModeFailoverReasoning derives a failover attempt body from
  245. // the canonical request body by dropping provider-specific encrypted reasoning
  246. // input items in full (encrypted_content plus the coupled id/summary shape).
  247. //
  248. // This is the proactive counterpart to the reactive same-account
  249. // invalid_encrypted_content recovery in Forward: when a failover switches from an
  250. // OpenAI passthrough account (which forwards upstream-native encrypted reasoning,
  251. // e.g. Kiro) to a non-passthrough account (e.g. Bedrock Mantle) that rejects the
  252. // provider-specific reasoning IDs/shape, the whole reasoning item must go before
  253. // the request reaches the new upstream. Unlike trimOpenAIEncryptedReasoningItems,
  254. // which only strips the encrypted_content / null-content fields while preserving
  255. // the reasoning item's id and summary, this drops the entire item.
  256. //
  257. // The input slice is treated as immutable and is never mutated; a distinct slice
  258. // is returned only when changed is true.
  259. func SanitizeOpenAICrossModeFailoverReasoning(body []byte) (sanitized []byte, changed bool, err error) {
  260. if len(body) == 0 {
  261. return body, false, nil
  262. }
  263. if !gjson.GetBytes(body, "input").Exists() {
  264. return body, false, nil
  265. }
  266. var decoded map[string]any
  267. if err := decodeOpenAIJSONUseNumber(body, &decoded); err != nil {
  268. return body, false, fmt.Errorf("decode cross-mode failover body: %w", err)
  269. }
  270. if !dropOpenAIEncryptedReasoningInputItems(decoded) {
  271. return body, false, nil
  272. }
  273. out, marshalErr := marshalOpenAIUpstreamJSON(decoded)
  274. if marshalErr != nil {
  275. return body, false, fmt.Errorf("serialize cross-mode failover body: %w", marshalErr)
  276. }
  277. return out, true, nil
  278. }
  279. // dropOpenAIEncryptedReasoningInputItems removes reasoning input items that carry
  280. // provider-specific encrypted_content in full — including their coupled id and
  281. // summary — and reports whether anything changed. Contrast with
  282. // trimOpenAIEncryptedReasoningItems, which only strips fields while keeping the
  283. // reasoning item skeleton.
  284. func dropOpenAIEncryptedReasoningInputItems(reqBody map[string]any) bool {
  285. if len(reqBody) == 0 {
  286. return false
  287. }
  288. inputValue, has := reqBody["input"]
  289. if !has {
  290. return false
  291. }
  292. switch input := inputValue.(type) {
  293. case []any:
  294. filtered := input[:0]
  295. changed := false
  296. for _, item := range input {
  297. if isOpenAIEncryptedReasoningInputItem(item) {
  298. changed = true
  299. continue
  300. }
  301. filtered = append(filtered, item)
  302. }
  303. if !changed {
  304. return false
  305. }
  306. if len(filtered) == 0 {
  307. delete(reqBody, "input")
  308. return true
  309. }
  310. reqBody["input"] = filtered
  311. return true
  312. case []map[string]any:
  313. filtered := input[:0]
  314. changed := false
  315. for _, item := range input {
  316. if isOpenAIEncryptedReasoningInputItem(item) {
  317. changed = true
  318. continue
  319. }
  320. filtered = append(filtered, item)
  321. }
  322. if !changed {
  323. return false
  324. }
  325. if len(filtered) == 0 {
  326. delete(reqBody, "input")
  327. return true
  328. }
  329. reqBody["input"] = filtered
  330. return true
  331. case map[string]any:
  332. if isOpenAIEncryptedReasoningInputItem(input) {
  333. delete(reqBody, "input")
  334. return true
  335. }
  336. return false
  337. default:
  338. return false
  339. }
  340. }
  341. func isOpenAIEncryptedReasoningInputItem(item any) bool {
  342. inputItem, ok := item.(map[string]any)
  343. if !ok {
  344. return false
  345. }
  346. if itemType, _ := inputItem["type"].(string); strings.TrimSpace(itemType) != "reasoning" {
  347. return false
  348. }
  349. _, has := inputItem["encrypted_content"]
  350. return has
  351. }
  352. // IsOpenAIResponsesCompactPath reports whether the request targets the legacy
  353. // /responses/compact endpoint, including its forwardable subpaths.
  354. func IsOpenAIResponsesCompactPath(c *gin.Context) bool {
  355. return isOpenAIResponsesCompactPath(c)
  356. }
  357. func OpenAICompactSessionSeedKeyForTest() string {
  358. return openAICompactSessionSeedKey
  359. }
  360. func NormalizeOpenAICompactRequestBodyForTest(body []byte) ([]byte, bool, error) {
  361. return normalizeOpenAICompactRequestBody(body)
  362. }
  363. func isOpenAIResponsesCompactPath(c *gin.Context) bool {
  364. suffix := strings.TrimSpace(openAIResponsesRequestPathSuffix(c))
  365. return suffix == "/compact" || strings.HasPrefix(suffix, "/compact/")
  366. }
  367. func normalizeOpenAICompactRequestBody(body []byte) ([]byte, bool, error) {
  368. if len(body) == 0 {
  369. return body, false, nil
  370. }
  371. normalized := []byte(`{}`)
  372. // Keep the current Codex /compact schema while still dropping request-scoped
  373. // fields such as prompt_cache_key, store, and stream.
  374. for _, field := range []string{
  375. "model",
  376. "input",
  377. "instructions",
  378. "tools",
  379. "parallel_tool_calls",
  380. "reasoning",
  381. "service_tier",
  382. "text",
  383. "previous_response_id",
  384. } {
  385. value := gjson.GetBytes(body, field)
  386. if !value.Exists() {
  387. continue
  388. }
  389. next, err := sjson.SetRawBytes(normalized, field, []byte(value.Raw))
  390. if err != nil {
  391. return body, false, fmt.Errorf("normalize compact body %s: %w", field, err)
  392. }
  393. normalized = next
  394. }
  395. if next, removed, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, false); err != nil {
  396. return body, false, err
  397. } else if removed {
  398. normalized = next
  399. }
  400. if bytes.Equal(bytes.TrimSpace(body), bytes.TrimSpace(normalized)) {
  401. return body, false, nil
  402. }
  403. return normalized, true, nil
  404. }
  405. func normalizeOpenAIParallelToolCallsWithoutTools(body []byte, responsesLite bool) ([]byte, bool, error) {
  406. if responsesLite {
  407. return body, false, nil
  408. }
  409. parallel := gjson.GetBytes(body, "parallel_tool_calls")
  410. if !parallel.Exists() {
  411. return body, false, nil
  412. }
  413. if openAIRequestBodyHasTools(body) {
  414. return body, false, nil
  415. }
  416. normalized, err := sjson.DeleteBytes(body, "parallel_tool_calls")
  417. if err != nil {
  418. return body, false, fmt.Errorf("normalize parallel_tool_calls without tools: %w", err)
  419. }
  420. return normalized, true, nil
  421. }
  422. // openAIRequestBodyHasTools 同时识别顶层 tools 和 input[].additional_tools。
  423. func openAIRequestBodyHasTools(body []byte) bool {
  424. if tools := gjson.GetBytes(body, "tools"); tools.IsArray() && len(tools.Array()) > 0 {
  425. return true
  426. }
  427. for _, item := range gjson.GetBytes(body, "input").Array() {
  428. if strings.TrimSpace(item.Get("type").String()) != "additional_tools" {
  429. continue
  430. }
  431. if tools := item.Get("tools"); tools.IsArray() && len(tools.Array()) > 0 {
  432. return true
  433. }
  434. }
  435. return false
  436. }
  437. // normalizeOpenAIResponsesReasoningContentReplay removes non-portable
  438. // reasoning.content arrays before history is sent to a real OpenAI Responses
  439. // endpoint. Compatible providers may return visible reasoning blocks there,
  440. // while OpenAI accepts only an empty array when the item is replayed.
  441. //
  442. // Keep the reasoning item and its portable fields (summary, encrypted_content,
  443. // ids, and opaque extensions). Callers scope this normalization to OpenAI
  444. // destinations; compatible providers may still consume their own content.
  445. func normalizeOpenAIResponsesReasoningContentReplay(body []byte) ([]byte, bool, error) {
  446. input := gjson.GetBytes(body, "input")
  447. if !input.IsArray() {
  448. return body, false, nil
  449. }
  450. needsNormalization := false
  451. input.ForEach(func(_, item gjson.Result) bool {
  452. if strings.TrimSpace(item.Get("type").String()) != "reasoning" {
  453. return true
  454. }
  455. content := item.Get("content")
  456. if content.IsArray() && len(content.Array()) > 0 {
  457. needsNormalization = true
  458. return false
  459. }
  460. return true
  461. })
  462. if !needsNormalization {
  463. return body, false, nil
  464. }
  465. var reqBody map[string]any
  466. if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
  467. return body, false, fmt.Errorf("normalize OpenAI reasoning content replay: %w", err)
  468. }
  469. items, ok := reqBody["input"].([]any)
  470. if !ok {
  471. return body, false, nil
  472. }
  473. changed := false
  474. for _, rawItem := range items {
  475. item, ok := rawItem.(map[string]any)
  476. if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "reasoning" {
  477. continue
  478. }
  479. content, ok := item["content"].([]any)
  480. if !ok || len(content) == 0 {
  481. continue
  482. }
  483. delete(item, "content")
  484. changed = true
  485. }
  486. if !changed {
  487. return body, false, nil
  488. }
  489. normalized, err := marshalOpenAIUpstreamJSON(reqBody)
  490. if err != nil {
  491. return body, false, fmt.Errorf("serialize normalized OpenAI reasoning content replay: %w", err)
  492. }
  493. return normalized, true, nil
  494. }
  495. func normalizeOpenAIAPIKeyStoreFalseReasoningReplay(body []byte, knownStoreFalse bool) ([]byte, bool, error) {
  496. if !knownStoreFalse && gjson.GetBytes(body, "store").Type != gjson.False {
  497. return body, false, nil
  498. }
  499. input := gjson.GetBytes(body, "input")
  500. if !input.IsArray() {
  501. return body, false, nil
  502. }
  503. var reqBody map[string]any
  504. if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
  505. return body, false, fmt.Errorf("normalize API-key store=false reasoning replay: %w", err)
  506. }
  507. items, ok := reqBody["input"].([]any)
  508. if !ok {
  509. return body, false, nil
  510. }
  511. filtered := make([]any, 0, len(items))
  512. changed := false
  513. for _, rawItem := range items {
  514. item, ok := rawItem.(map[string]any)
  515. if !ok {
  516. filtered = append(filtered, rawItem)
  517. continue
  518. }
  519. typ := strings.TrimSpace(firstNonEmptyString(item["type"]))
  520. id := strings.TrimSpace(firstNonEmptyString(item["id"]))
  521. switch typ {
  522. case "reasoning":
  523. encryptedContent, hasEncryptedContent := item["encrypted_content"].(string)
  524. if !hasEncryptedContent || strings.TrimSpace(encryptedContent) == "" {
  525. changed = true
  526. continue
  527. }
  528. if strings.HasPrefix(id, "rs_") {
  529. delete(item, "id")
  530. changed = true
  531. }
  532. if summary, ok := item["summary"]; !ok || summary == nil {
  533. item["summary"] = []any{}
  534. changed = true
  535. }
  536. case "item_reference":
  537. if strings.HasPrefix(id, "rs_") {
  538. changed = true
  539. continue
  540. }
  541. }
  542. if shouldStripOpenAIResponsesNonPairCallID(typ) {
  543. if _, hasCallID := item["call_id"]; hasCallID {
  544. delete(item, "call_id")
  545. changed = true
  546. }
  547. }
  548. filtered = append(filtered, item)
  549. }
  550. if !changed {
  551. return body, false, nil
  552. }
  553. reqBody["input"] = filtered
  554. normalized, err := marshalOpenAIUpstreamJSON(reqBody)
  555. if err != nil {
  556. return body, false, fmt.Errorf("serialize API-key store=false reasoning replay: %w", err)
  557. }
  558. return normalized, true, nil
  559. }
  560. func normalizeOpenAICodexCompactReasoningEffortForAccount(c *gin.Context, account *Account, body []byte) ([]byte, bool, error) {
  561. if account == nil || !account.IsOpenAIOAuthLike() || !isOpenAIResponsesCompactPath(c) {
  562. return body, false, nil
  563. }
  564. requestedModel := strings.TrimSpace(gjson.GetBytes(body, "model").String())
  565. effectiveModel := account.GetMappedModel(requestedModel)
  566. return normalizeOpenAICodexCompactReasoningEffort(body, effectiveModel)
  567. }
  568. func normalizeOpenAICodexCompactReasoningEffort(body []byte, effectiveModel string) ([]byte, bool, error) {
  569. if !isOpenAIGPT56Model(effectiveModel) ||
  570. !strings.EqualFold(strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String()), "max") {
  571. return body, false, nil
  572. }
  573. // Codex Ultra 在客户端编排层会下发 max;ChatGPT compact 端点目前只接受到
  574. // xhigh。这里只降级 OpenAI OAuth 的 GPT-5.6 compact 子请求,普通 Responses、
  575. // API Key 请求和其他平台的 OAuth 请求保留 max。
  576. normalized, err := sjson.SetBytes(body, "reasoning.effort", "xhigh")
  577. if err != nil {
  578. return body, false, fmt.Errorf("normalize codex compact reasoning effort: %w", err)
  579. }
  580. return normalized, true, nil
  581. }
  582. func resolveOpenAICompactSessionID(c *gin.Context) string {
  583. if c != nil {
  584. if sessionID := strings.TrimSpace(c.GetHeader("session_id")); sessionID != "" {
  585. return sessionID
  586. }
  587. if conversationID := strings.TrimSpace(c.GetHeader("conversation_id")); conversationID != "" {
  588. return conversationID
  589. }
  590. if seed, ok := c.Get(openAICompactSessionSeedKey); ok {
  591. if seedStr, ok := seed.(string); ok && strings.TrimSpace(seedStr) != "" {
  592. return strings.TrimSpace(seedStr)
  593. }
  594. }
  595. }
  596. return uuid.NewString()
  597. }
  598. // openAIResponsesRequestPathSuffix 返回可拼接到上游 /responses URL 后面的子路径。
  599. // 不可转发的子路径返回空串(退化为裸 /responses);真正的拒绝由入口守卫
  600. // IsForwardableOpenAIResponsesRequestPath 负责。这样即便将来新增路由漏挂守卫,
  601. // 拼进上游 URL 的也只会是合规片段。
  602. func openAIResponsesRequestPathSuffix(c *gin.Context) string {
  603. suffix, ok := sanitizedUpstreamPathSuffix(rawOpenAIResponsesRequestPathSuffix(c))
  604. if !ok {
  605. return ""
  606. }
  607. return suffix
  608. }
  609. // IsForwardableOpenAIResponsesRequestPath 判断入站请求携带的 /responses 子路径
  610. // 是否可以安全转发。路由层用它在鉴权后、调度前直接拒绝畸形子路径。
  611. func IsForwardableOpenAIResponsesRequestPath(c *gin.Context) bool {
  612. _, ok := sanitizedUpstreamPathSuffix(rawOpenAIResponsesRequestPathSuffix(c))
  613. return ok
  614. }
  615. // IsOpenAIResponsesInputTokensRequestPath reports whether the request targets
  616. // the native Responses input-token counting endpoint.
  617. func IsOpenAIResponsesInputTokensRequestPath(c *gin.Context) bool {
  618. return openAIResponsesRequestPathSuffix(c) == "/input_tokens"
  619. }
  620. // rawOpenAIResponsesRequestPathSuffix 仅做提取,不做任何安全判断。
  621. func rawOpenAIResponsesRequestPathSuffix(c *gin.Context) string {
  622. if c == nil || c.Request == nil || c.Request.URL == nil {
  623. return ""
  624. }
  625. normalizedPath := strings.TrimRight(strings.TrimSpace(c.Request.URL.Path), "/")
  626. if normalizedPath == "" {
  627. return ""
  628. }
  629. idx := strings.LastIndex(normalizedPath, "/responses")
  630. if idx < 0 {
  631. return ""
  632. }
  633. suffix := normalizedPath[idx+len("/responses"):]
  634. if suffix == "" || suffix == "/" {
  635. return ""
  636. }
  637. if !strings.HasPrefix(suffix, "/") {
  638. return ""
  639. }
  640. return suffix
  641. }
  642. func appendOpenAIResponsesRequestPathSuffix(baseURL, suffix string) string {
  643. trimmedBase := strings.TrimRight(strings.TrimSpace(baseURL), "/")
  644. // 兜底:调用方漏了校验时,这里也不会把不合规的片段拼进上游 URL。
  645. trimmedSuffix, ok := sanitizedUpstreamPathSuffix(suffix)
  646. if !ok || trimmedBase == "" || trimmedSuffix == "" {
  647. return trimmedBase
  648. }
  649. return trimmedBase + trimmedSuffix
  650. }
  651. func (s *OpenAIGatewayService) replaceModelInResponseBody(body []byte, fromModel, toModel string) []byte {
  652. // 使用 gjson/sjson 精确替换 model 字段,避免全量 JSON 反序列化
  653. if m := gjson.GetBytes(body, "model"); m.Exists() && m.Str == fromModel {
  654. newBody, err := sjson.SetBytes(body, "model", toModel)
  655. if err != nil {
  656. return body
  657. }
  658. return newBody
  659. }
  660. return body
  661. }
  662. func getOpenAIReasoningEffortFromReqBody(reqBody map[string]any, requestedModel string) (value string, present bool) {
  663. if reqBody == nil {
  664. return "", false
  665. }
  666. // Primary: reasoning.effort
  667. if reasoning, ok := reqBody["reasoning"].(map[string]any); ok {
  668. if effort, ok := reasoning["effort"].(string); ok {
  669. return normalizeOpenAIReasoningEffortForModel(effort, requestedModel), true
  670. }
  671. }
  672. // Fallback: some clients may use a flat field.
  673. if effort, ok := reqBody["reasoning_effort"].(string); ok {
  674. return normalizeOpenAIReasoningEffortForModel(effort, requestedModel), true
  675. }
  676. return "", false
  677. }
  678. func deriveOpenAIReasoningEffortFromModel(model string) string {
  679. if strings.TrimSpace(model) == "" {
  680. return ""
  681. }
  682. modelID := strings.TrimSpace(model)
  683. if strings.Contains(modelID, "/") {
  684. parts := strings.Split(modelID, "/")
  685. modelID = parts[len(parts)-1]
  686. }
  687. parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool {
  688. switch r {
  689. case '-', '_', ' ':
  690. return true
  691. default:
  692. return false
  693. }
  694. })
  695. if len(parts) == 0 {
  696. return ""
  697. }
  698. return normalizeOpenAIReasoningEffortForModel(parts[len(parts)-1], modelID)
  699. }
  700. // deriveOpenAIReasoningEffortFromModelCandidates 依次对每个候选模型做后缀推导,
  701. // 返回第一个非空结果。
  702. func deriveOpenAIReasoningEffortFromModelCandidates(models []string) string {
  703. for _, model := range models {
  704. if value := deriveOpenAIReasoningEffortFromModel(model); value != "" {
  705. return value
  706. }
  707. }
  708. return ""
  709. }
  710. type openAIRequestView struct {
  711. body []byte
  712. Model string
  713. Stream bool
  714. PromptCacheKey string
  715. PreviousResponseID string
  716. ServiceTier string
  717. ReasoningEffort string
  718. patches []openAIRequestPatch
  719. patchesDisabled bool
  720. }
  721. type openAIRequestPatch struct {
  722. path string
  723. delete bool
  724. value any
  725. }
  726. func newOpenAIRequestView(body []byte) openAIRequestView {
  727. if len(body) == 0 {
  728. return openAIRequestView{}
  729. }
  730. const (
  731. modelField uint8 = 1 << iota
  732. streamField
  733. promptCacheKeyField
  734. previousResponseIDField
  735. serviceTierField
  736. reasoningField
  737. allRequestViewFields = modelField | streamField | promptCacheKeyField |
  738. previousResponseIDField | serviceTierField | reasoningField
  739. )
  740. view := openAIRequestView{body: body}
  741. var seen uint8
  742. // parseRawJSONView reads body without copying; view keeps body alive for extracted strings.
  743. parseRawJSONView(body).ForEach(func(key, value gjson.Result) bool {
  744. switch key.Str {
  745. case "model":
  746. if seen&modelField == 0 {
  747. view.Model = strings.TrimSpace(value.String())
  748. seen |= modelField
  749. }
  750. case "stream":
  751. if seen&streamField == 0 {
  752. view.Stream = value.Bool()
  753. seen |= streamField
  754. }
  755. case "prompt_cache_key":
  756. if seen&promptCacheKeyField == 0 {
  757. view.PromptCacheKey = strings.TrimSpace(value.String())
  758. seen |= promptCacheKeyField
  759. }
  760. case "previous_response_id":
  761. if seen&previousResponseIDField == 0 {
  762. view.PreviousResponseID = strings.TrimSpace(value.String())
  763. seen |= previousResponseIDField
  764. }
  765. case "service_tier":
  766. if seen&serviceTierField == 0 {
  767. view.ServiceTier = strings.TrimSpace(value.String())
  768. seen |= serviceTierField
  769. }
  770. case "reasoning":
  771. if seen&reasoningField == 0 {
  772. view.ReasoningEffort = strings.TrimSpace(value.Get("effort").String())
  773. seen |= reasoningField
  774. }
  775. }
  776. return seen != allRequestViewFields
  777. })
  778. return view
  779. }
  780. // Decode 保留阶段一既有 full-map 行为;后续阶段会把调用点下沉到复杂分支。
  781. func (v openAIRequestView) Decode(c *gin.Context) (map[string]any, error) {
  782. return getOpenAIRequestBodyMap(c, v.body)
  783. }
  784. func (v *openAIRequestView) MarkPatchSet(path string, value any) {
  785. if v == nil || v.patchesDisabled {
  786. return
  787. }
  788. path = strings.TrimSpace(path)
  789. if !isSimpleOpenAIRequestPatchPath(path) {
  790. v.DisablePatches()
  791. return
  792. }
  793. v.patches = append(v.patches, openAIRequestPatch{path: path, value: value})
  794. }
  795. func (v *openAIRequestView) MarkPatchDelete(path string) {
  796. if v == nil || v.patchesDisabled {
  797. return
  798. }
  799. path = strings.TrimSpace(path)
  800. if !isSimpleOpenAIRequestPatchPath(path) {
  801. v.DisablePatches()
  802. return
  803. }
  804. v.patches = append(v.patches, openAIRequestPatch{path: path, delete: true})
  805. }
  806. func isSimpleOpenAIRequestPatchPath(path string) bool {
  807. if path == "" || strings.ContainsRune(path, '\\') {
  808. return false
  809. }
  810. for _, part := range strings.Split(path, ".") {
  811. if strings.TrimSpace(part) == "" {
  812. return false
  813. }
  814. }
  815. return true
  816. }
  817. func (v *openAIRequestView) DisablePatches() {
  818. if v == nil {
  819. return
  820. }
  821. v.patchesDisabled = true
  822. v.patches = nil
  823. }
  824. func (v openAIRequestView) HasPatches() bool {
  825. return !v.patchesDisabled && len(v.patches) > 0
  826. }
  827. func (v openAIRequestView) ApplyPatches() ([]byte, error) {
  828. if v.patchesDisabled || len(v.patches) == 0 {
  829. return nil, errors.New("openai request patches disabled")
  830. }
  831. body := v.body
  832. for _, patch := range v.patches {
  833. var err error
  834. if patch.delete {
  835. body, err = sjson.DeleteBytes(body, patch.path)
  836. } else {
  837. body, err = sjson.SetBytes(body, patch.path, patch.value)
  838. }
  839. if err != nil {
  840. return nil, err
  841. }
  842. }
  843. return body, nil
  844. }
  845. func setOpenAIRequestMapPath(reqBody map[string]any, path string, value any) {
  846. path = strings.TrimSpace(path)
  847. if reqBody == nil || path == "" {
  848. return
  849. }
  850. parts := strings.Split(path, ".")
  851. current := reqBody
  852. for _, part := range parts[:len(parts)-1] {
  853. part = strings.TrimSpace(part)
  854. if part == "" {
  855. return
  856. }
  857. next, _ := current[part].(map[string]any)
  858. if next == nil {
  859. next = map[string]any{}
  860. current[part] = next
  861. }
  862. current = next
  863. }
  864. last := strings.TrimSpace(parts[len(parts)-1])
  865. if last != "" {
  866. current[last] = value
  867. }
  868. }
  869. func deleteOpenAIRequestMapPath(reqBody map[string]any, path string) {
  870. path = strings.TrimSpace(path)
  871. if reqBody == nil || path == "" {
  872. return
  873. }
  874. parts := strings.Split(path, ".")
  875. current := reqBody
  876. for _, part := range parts[:len(parts)-1] {
  877. part = strings.TrimSpace(part)
  878. if part == "" {
  879. return
  880. }
  881. next, _ := current[part].(map[string]any)
  882. if next == nil {
  883. return
  884. }
  885. current = next
  886. }
  887. last := strings.TrimSpace(parts[len(parts)-1])
  888. if last != "" {
  889. delete(current, last)
  890. }
  891. }
  892. func extractOpenAIRequestMetaFromBody(body []byte) (model string, stream bool, promptCacheKey string) {
  893. view := newOpenAIRequestView(body)
  894. return view.Model, view.Stream, view.PromptCacheKey
  895. }
  896. func normalizeOpenAIOAuthResponsesCompatibilityFields(reqBody map[string]any) bool {
  897. if reqBody == nil {
  898. return false
  899. }
  900. changed := false
  901. if prompt, exists := reqBody["prompt"]; exists {
  902. if input, hasInput := reqBody["input"]; !hasInput || input == nil {
  903. if prompt != nil {
  904. reqBody["input"] = prompt
  905. }
  906. }
  907. delete(reqBody, "prompt")
  908. changed = true
  909. }
  910. if _, exists := reqBody["commands"]; exists {
  911. delete(reqBody, "commands")
  912. changed = true
  913. }
  914. return changed
  915. }
  916. func normalizeOpenAIOAuthResponsesCompatibilityBody(body []byte) ([]byte, bool, error) {
  917. if len(body) == 0 {
  918. return body, false, nil
  919. }
  920. normalized := body
  921. changed := false
  922. prompt := gjson.GetBytes(normalized, "prompt")
  923. if prompt.Exists() {
  924. input := gjson.GetBytes(normalized, "input")
  925. if prompt.Type != gjson.Null && (!input.Exists() || input.Type == gjson.Null) {
  926. next, err := sjson.SetRawBytes(normalized, "input", []byte(prompt.Raw))
  927. if err != nil {
  928. return body, false, fmt.Errorf("normalize oauth responses prompt: %w", err)
  929. }
  930. normalized = next
  931. }
  932. next, err := sjson.DeleteBytes(normalized, "prompt")
  933. if err != nil {
  934. return body, false, fmt.Errorf("normalize oauth responses delete prompt: %w", err)
  935. }
  936. normalized = next
  937. changed = true
  938. }
  939. if gjson.GetBytes(normalized, "commands").Exists() {
  940. next, err := sjson.DeleteBytes(normalized, "commands")
  941. if err != nil {
  942. return body, false, fmt.Errorf("normalize oauth responses delete commands: %w", err)
  943. }
  944. normalized = next
  945. changed = true
  946. }
  947. return normalized, changed, nil
  948. }
  949. func normalizeOpenAIResponsesReasoningMode(body []byte) ([]byte, bool, error) {
  950. if len(body) == 0 {
  951. return body, false, nil
  952. }
  953. // Astra 的 reasoning.mode 与 reasoning.effort 是独立参数,不做兼容替换;非 Astra 维持旧 strip-mode/pro->max 行为。
  954. if isOpenAIGPT6AstraModel(gjson.GetBytes(body, "model").String()) {
  955. return body, false, nil
  956. }
  957. mode := gjson.GetBytes(body, "reasoning.mode")
  958. if !mode.Exists() || mode.Type != gjson.String {
  959. return body, false, nil
  960. }
  961. updated := body
  962. effort := gjson.GetBytes(body, "reasoning.effort")
  963. if (!effort.Exists() || effort.Type == gjson.Null || strings.TrimSpace(effort.String()) == "") &&
  964. strings.EqualFold(strings.TrimSpace(mode.String()), "pro") {
  965. var err error
  966. updated, err = sjson.SetBytes(updated, "reasoning.effort", "max")
  967. if err != nil {
  968. return body, false, fmt.Errorf("set reasoning effort for mode=pro: %w", err)
  969. }
  970. }
  971. updated, err := sjson.DeleteBytes(updated, "reasoning.mode")
  972. if err != nil {
  973. return body, false, fmt.Errorf("delete unsupported reasoning.mode: %w", err)
  974. }
  975. if reasoning := gjson.GetBytes(updated, "reasoning"); reasoning.Exists() && reasoning.IsObject() && len(reasoning.Map()) == 0 {
  976. updated, err = sjson.DeleteBytes(updated, "reasoning")
  977. if err != nil {
  978. return body, false, fmt.Errorf("delete empty reasoning object: %w", err)
  979. }
  980. }
  981. return updated, true, nil
  982. }
  983. func normalizeOpenAIResponseFormatSchemasBody(body []byte) ([]byte, bool, error) {
  984. if len(body) == 0 {
  985. return body, false, nil
  986. }
  987. textFormat := strings.TrimSpace(gjson.GetBytes(body, "text.format.type").String())
  988. responseFormat := strings.TrimSpace(gjson.GetBytes(body, "response_format.type").String())
  989. if textFormat != "json_schema" && responseFormat != "json_schema" {
  990. return body, false, nil
  991. }
  992. var reqBody map[string]any
  993. if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
  994. return body, false, fmt.Errorf("normalize responses schema body: %w", err)
  995. }
  996. if !normalizeOpenAIResponseFormatSchemas(reqBody) {
  997. return body, false, nil
  998. }
  999. normalized, err := json.Marshal(reqBody)
  1000. if err != nil {
  1001. return body, false, fmt.Errorf("serialize normalized responses schema body: %w", err)
  1002. }
  1003. return normalized, true, nil
  1004. }
  1005. func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Account, responsesLite bool) ([]byte, bool, error) {
  1006. if account == nil || !account.IsOpenAI() {
  1007. return body, false, nil
  1008. }
  1009. normalized := body
  1010. changed := false
  1011. if account.IsOpenAIOAuthLike() {
  1012. var err error
  1013. normalized, changed, err = normalizeOpenAIResponsesLegacyIngress(body)
  1014. if err != nil {
  1015. return body, false, err
  1016. }
  1017. }
  1018. if next, normalizedReasoningContent, err := normalizeOpenAIResponsesReasoningContentReplay(normalized); err != nil {
  1019. return body, false, err
  1020. } else if normalizedReasoningContent {
  1021. normalized = next
  1022. changed = true
  1023. }
  1024. if account.IsOpenAIApiKey() {
  1025. if next, normalizedParallel, err := normalizeOpenAIParallelToolCallsWithoutTools(normalized, responsesLite); err != nil {
  1026. return body, false, err
  1027. } else if normalizedParallel {
  1028. normalized = next
  1029. changed = true
  1030. }
  1031. if next, normalizedReasoning, err := normalizeOpenAIAPIKeyStoreFalseReasoningReplay(normalized, false); err != nil {
  1032. return body, false, err
  1033. } else if normalizedReasoning {
  1034. normalized = next
  1035. changed = true
  1036. }
  1037. }
  1038. if sanitized, idsChanged, err := sanitizeOpenAIResponsesInputItemIDs(normalized); err != nil {
  1039. return body, false, fmt.Errorf("sanitize websocket Responses input item IDs: %w", err)
  1040. } else if idsChanged {
  1041. normalized = sanitized
  1042. changed = true
  1043. }
  1044. if account != nil && account.IsOpenAI() && account.IsOAuth() {
  1045. if reasoningBody, reasoningChanged, err := normalizeOpenAIResponsesReasoningMode(normalized); err != nil {
  1046. return body, false, err
  1047. } else if reasoningChanged {
  1048. normalized = reasoningBody
  1049. changed = true
  1050. }
  1051. }
  1052. if account != nil && account.IsOpenAIOAuthLike() {
  1053. oauthBody, oauthChanged, err := normalizeOpenAIOAuthResponsesCompatibilityBody(normalized)
  1054. if err != nil {
  1055. return body, false, err
  1056. }
  1057. normalized = oauthBody
  1058. changed = changed || oauthChanged
  1059. for _, field := range openAIChatGPTInternalUnsupportedFields {
  1060. if !gjson.GetBytes(normalized, field).Exists() {
  1061. continue
  1062. }
  1063. next, deleteErr := sjson.DeleteBytes(normalized, field)
  1064. if deleteErr != nil {
  1065. return body, false, fmt.Errorf("normalize websocket body delete %s: %w", field, deleteErr)
  1066. }
  1067. normalized = next
  1068. changed = true
  1069. }
  1070. }
  1071. needsOrphanCleanup := account != nil && account.IsOpenAIOAuthLike() &&
  1072. gjson.GetBytes(normalized, "input").IsArray()
  1073. if needsOrphanCleanup || openAIResponsesInputMayNeedTruncation(normalized) {
  1074. var reqBody map[string]any
  1075. if err := decodeOpenAIJSONUseNumber(normalized, &reqBody); err != nil {
  1076. return body, false, fmt.Errorf("normalize websocket Responses body: %w", err)
  1077. }
  1078. mapChanged := false
  1079. if needsOrphanCleanup {
  1080. if input, ok := reqBody["input"].([]any); ok && sanitizeOpenAIResponsesOrphanToolOutputs(
  1081. reqBody,
  1082. input,
  1083. strings.TrimSpace(firstNonEmptyString(reqBody["previous_response_id"])) != "",
  1084. ) {
  1085. mapChanged = true
  1086. }
  1087. }
  1088. if truncateOpenAIResponsesInputText(reqBody) {
  1089. mapChanged = true
  1090. }
  1091. if mapChanged {
  1092. next, err := marshalOpenAIUpstreamJSON(reqBody)
  1093. if err != nil {
  1094. return body, false, fmt.Errorf("serialize normalized websocket Responses body: %w", err)
  1095. }
  1096. normalized = next
  1097. changed = true
  1098. }
  1099. }
  1100. if schemaBody, schemaChanged, err := normalizeOpenAIResponseFormatSchemasBody(normalized); err != nil {
  1101. return body, false, err
  1102. } else if schemaChanged {
  1103. normalized = schemaBody
  1104. changed = true
  1105. }
  1106. if openAIRequestBodyImageGenerationToolNeedsNormalization(normalized) {
  1107. var reqBody map[string]any
  1108. if err := json.Unmarshal(normalized, &reqBody); err != nil {
  1109. return body, false, fmt.Errorf("normalize websocket image tool body: %w", err)
  1110. }
  1111. if normalizeOpenAIResponsesImageGenerationTools(reqBody) {
  1112. next, err := json.Marshal(reqBody)
  1113. if err != nil {
  1114. return body, false, fmt.Errorf("serialize normalized websocket image tool body: %w", err)
  1115. }
  1116. normalized = next
  1117. changed = true
  1118. }
  1119. }
  1120. if account != nil {
  1121. if schemaBody, schemaChanged, err := sanitizeOpenAIResponsesToolSchemasForPlatform(normalized, account.Platform); err != nil {
  1122. return body, false, fmt.Errorf("normalize websocket tool schemas: %w", err)
  1123. } else if schemaChanged {
  1124. normalized = schemaBody
  1125. changed = true
  1126. }
  1127. }
  1128. // Keep this last: earlier compatibility passes may filter or rebuild input.
  1129. // Remote compaction v2 requires one trigger as the final input item.
  1130. if triggerBody, triggerChanged, err := NormalizeCompactionTriggerInputOrder(normalized); err != nil {
  1131. return body, false, fmt.Errorf("normalize websocket compaction trigger order: %w", err)
  1132. } else if triggerChanged {
  1133. normalized = triggerBody
  1134. changed = true
  1135. }
  1136. return normalized, changed, nil
  1137. }
  1138. // normalizeOpenAIPassthroughOAuthBody 将透传 OAuth 请求体收敛为旧链路关键行为:
  1139. // 1) 删除 ChatGPT internal API 不支持的顶层 Responses 参数
  1140. // 2) store=false 3) 非 compact 保持 stream=true;compact 强制 stream=false
  1141. func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, bool, error) {
  1142. if len(body) == 0 {
  1143. return body, false, nil
  1144. }
  1145. normalized, changed, err := normalizeOpenAIOAuthResponsesCompatibilityBody(body)
  1146. if err != nil {
  1147. return body, false, err
  1148. }
  1149. if reasoningBody, reasoningChanged, reasoningErr := normalizeOpenAIResponsesReasoningMode(normalized); reasoningErr != nil {
  1150. return body, false, reasoningErr
  1151. } else if reasoningChanged {
  1152. normalized = reasoningBody
  1153. changed = true
  1154. }
  1155. for _, field := range openAIChatGPTInternalUnsupportedFields {
  1156. if value := gjson.GetBytes(normalized, field); !value.Exists() {
  1157. continue
  1158. }
  1159. next, err := sjson.DeleteBytes(normalized, field)
  1160. if err != nil {
  1161. return body, false, fmt.Errorf("normalize passthrough body delete %s: %w", field, err)
  1162. }
  1163. normalized = next
  1164. changed = true
  1165. }
  1166. if schemaBody, schemaChanged, schemaErr := normalizeOpenAIResponseFormatSchemasBody(normalized); schemaErr != nil {
  1167. return body, false, schemaErr
  1168. } else if schemaChanged {
  1169. normalized = schemaBody
  1170. changed = true
  1171. }
  1172. if inputResult := gjson.GetBytes(normalized, "input"); inputResult.Exists() {
  1173. switch {
  1174. case inputResult.Type == gjson.String:
  1175. text := inputResult.String()
  1176. var inputValue any
  1177. if strings.TrimSpace(text) != "" {
  1178. inputValue = []any{map[string]any{
  1179. "type": "message", "role": "user", "content": text,
  1180. }}
  1181. } else {
  1182. inputValue = []any{}
  1183. }
  1184. next, err := sjson.SetBytes(normalized, "input", inputValue)
  1185. if err != nil {
  1186. return body, false, fmt.Errorf("normalize passthrough body input string: %w", err)
  1187. }
  1188. normalized = next
  1189. changed = true
  1190. case inputResult.Type == gjson.JSON && !inputResult.IsArray():
  1191. next, err := sjson.SetRawBytes(normalized, "input", []byte("["+inputResult.Raw+"]"))
  1192. if err != nil {
  1193. return body, false, fmt.Errorf("normalize passthrough body input object: %w", err)
  1194. }
  1195. normalized = next
  1196. changed = true
  1197. }
  1198. }
  1199. if compact {
  1200. if store := gjson.GetBytes(normalized, "store"); store.Exists() {
  1201. next, err := sjson.DeleteBytes(normalized, "store")
  1202. if err != nil {
  1203. return body, false, fmt.Errorf("normalize passthrough body delete store: %w", err)
  1204. }
  1205. normalized = next
  1206. changed = true
  1207. }
  1208. if stream := gjson.GetBytes(normalized, "stream"); stream.Exists() {
  1209. next, err := sjson.DeleteBytes(normalized, "stream")
  1210. if err != nil {
  1211. return body, false, fmt.Errorf("normalize passthrough body delete stream: %w", err)
  1212. }
  1213. normalized = next
  1214. changed = true
  1215. }
  1216. } else {
  1217. if store := gjson.GetBytes(normalized, "store"); !store.Exists() || store.Type != gjson.False {
  1218. next, err := sjson.SetBytes(normalized, "store", false)
  1219. if err != nil {
  1220. return body, false, fmt.Errorf("normalize passthrough body store=false: %w", err)
  1221. }
  1222. normalized = next
  1223. changed = true
  1224. }
  1225. if stream := gjson.GetBytes(normalized, "stream"); !stream.Exists() || stream.Type != gjson.True {
  1226. next, err := sjson.SetBytes(normalized, "stream", true)
  1227. if err != nil {
  1228. return body, false, fmt.Errorf("normalize passthrough body stream=true: %w", err)
  1229. }
  1230. normalized = next
  1231. changed = true
  1232. }
  1233. }
  1234. return normalized, changed, nil
  1235. }
  1236. func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string {
  1237. if !isOpenAICodexModel(reqModel) {
  1238. return ""
  1239. }
  1240. instructions := gjson.GetBytes(body, "instructions")
  1241. if !instructions.Exists() {
  1242. return ""
  1243. }
  1244. if instructions.Type != gjson.String {
  1245. return "instructions_not_string"
  1246. }
  1247. if strings.TrimSpace(instructions.String()) == "" {
  1248. return "instructions_empty"
  1249. }
  1250. return ""
  1251. }
  1252. func isOpenAICodexModel(model string) bool {
  1253. return strings.Contains(strings.ToLower(strings.TrimSpace(model)), "codex")
  1254. }
  1255. // extractOpenAIReasoningEffortFromBody 按优先级传入模型候选(如 upstreamModel,
  1256. // billingModel, originalModel):显式 effort 的模型归一化(max 保留判定)用第一个
  1257. // 非空候选;body 未携带 effort 时的模型后缀推导依次尝试每个候选——OAuth 的
  1258. // normalizeCodexModel 会剥掉 upstreamModel 的 effort 后缀,只有原始模型名还留着。
  1259. func extractOpenAIReasoningEffortFromBody(body []byte, modelCandidates ...string) *string {
  1260. reasoningEffort := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String())
  1261. if reasoningEffort == "" {
  1262. reasoningEffort = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String())
  1263. }
  1264. if reasoningEffort != "" {
  1265. normalized := normalizeOpenAIReasoningEffortForModel(reasoningEffort, firstNonEmpty(modelCandidates...))
  1266. if normalized == "" {
  1267. return nil
  1268. }
  1269. return &normalized
  1270. }
  1271. value := deriveOpenAIReasoningEffortFromModelCandidates(modelCandidates)
  1272. if value == "" {
  1273. return nil
  1274. }
  1275. return &value
  1276. }
  1277. func explicitRequestedReasoningEffortFromBody(body []byte) string {
  1278. raw := strings.TrimSpace(gjson.GetBytes(body, "reasoning.effort").String())
  1279. if raw == "" {
  1280. raw = strings.TrimSpace(gjson.GetBytes(body, "reasoning_effort").String())
  1281. }
  1282. if raw == "" {
  1283. raw = strings.TrimSpace(gjson.GetBytes(body, "output_config.effort").String())
  1284. }
  1285. return raw
  1286. }
  1287. // CanonicalRequestedReasoningEffort extracts the client-requested effort before
  1288. // group policy rewriting and before model-family remapping (max -> xhigh).
  1289. // Empty or unknown values return nil. "max" is preserved even for models that
  1290. // later persist "xhigh".
  1291. func CanonicalRequestedReasoningEffort(body []byte, modelCandidates ...string) *string {
  1292. if raw := explicitRequestedReasoningEffortFromBody(body); raw != "" {
  1293. canonical := NormalizeMaxReasoningEffort(raw)
  1294. if canonical == "" {
  1295. return nil
  1296. }
  1297. return &canonical
  1298. }
  1299. for _, model := range modelCandidates {
  1300. if value := canonicalReasoningEffortFromModelSuffix(model); value != "" {
  1301. return &value
  1302. }
  1303. }
  1304. if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model != "" {
  1305. if value := canonicalReasoningEffortFromModelSuffix(model); value != "" {
  1306. return &value
  1307. }
  1308. }
  1309. return nil
  1310. }
  1311. func canonicalReasoningEffortFromModelSuffix(model string) string {
  1312. if strings.TrimSpace(model) == "" {
  1313. return ""
  1314. }
  1315. modelID := strings.TrimSpace(model)
  1316. if strings.Contains(modelID, "/") {
  1317. parts := strings.Split(modelID, "/")
  1318. modelID = parts[len(parts)-1]
  1319. }
  1320. parts := strings.FieldsFunc(strings.ToLower(modelID), func(r rune) bool {
  1321. switch r {
  1322. case '-', '_', ' ':
  1323. return true
  1324. default:
  1325. return false
  1326. }
  1327. })
  1328. if len(parts) == 0 {
  1329. return ""
  1330. }
  1331. return NormalizeMaxReasoningEffort(parts[len(parts)-1])
  1332. }
  1333. func extractOpenAIServiceTier(reqBody map[string]any) *string {
  1334. if reqBody == nil {
  1335. return nil
  1336. }
  1337. raw, ok := reqBody["service_tier"].(string)
  1338. if !ok {
  1339. return nil
  1340. }
  1341. return normalizeOpenAIServiceTier(raw)
  1342. }
  1343. func extractOpenAIServiceTierFromBody(body []byte) *string {
  1344. if len(body) == 0 {
  1345. return nil
  1346. }
  1347. return normalizeOpenAIServiceTier(gjson.GetBytes(body, "service_tier").String())
  1348. }
  1349. func normalizeOpenAIServiceTier(raw string) *string {
  1350. value := strings.ToLower(strings.TrimSpace(raw))
  1351. if value == "" {
  1352. return nil
  1353. }
  1354. if value == "fast" {
  1355. value = "priority"
  1356. }
  1357. // 放过 OpenAI 官方文档定义的合法 tier 值,以及 Codex/API 新增的 ultrafast。
  1358. // Codex 客户端会发 priority、flex 或 ultrafast;直连 OpenAI SDK 的用户还会
  1359. // 透传 auto/default/scale。真未知值仍返回 nil,由
  1360. // normalizeResponsesBodyServiceTier 从 body 中删除。
  1361. switch value {
  1362. case "priority", "flex", "auto", "default", "scale", OpenAIFastTierUltrafast:
  1363. return &value
  1364. default:
  1365. return nil
  1366. }
  1367. }
  1368. // ErrInvalidOpenAIServiceTier indicates a request carried a service_tier value
  1369. // that is not a known OpenAI tier. HTTP handlers translate it into a 400
  1370. // invalid_request_error so malformed values are rejected up front instead of
  1371. // being silently stripped (which would mask the user's intent to use fast
  1372. // mode).
  1373. type ErrInvalidOpenAIServiceTier struct {
  1374. Value string
  1375. }
  1376. func (e *ErrInvalidOpenAIServiceTier) Error() string {
  1377. return fmt.Sprintf("invalid service_tier %q: must be one of auto, default, fast, flex, priority, scale, ultrafast", e.Value)
  1378. }
  1379. const invalidOpenAIServiceTierValueMaxLen = 64
  1380. func boundInvalidOpenAIServiceTierValue(raw string) string {
  1381. if len(raw) <= invalidOpenAIServiceTierValueMaxLen {
  1382. return raw
  1383. }
  1384. return raw[:invalidOpenAIServiceTierValueMaxLen] + "..."
  1385. }
  1386. // ValidateOpenAIServiceTierField validates the service_tier field of a raw
  1387. // OpenAI-compatible request body (/v1/responses and /v1/chat/completions).
  1388. //
  1389. // - absent / null → valid, returns "" (field omitted keeps current behavior)
  1390. // - "fast" → normalized to "priority" (the two are equivalent; the canonical
  1391. // value is what reaches the OpenAI upstream)
  1392. // - "priority" / "flex" / "auto" / "default" / "scale" / "ultrafast" → valid, returned as-is
  1393. // - an explicitly present non-string value, an empty string, or any other
  1394. // unknown value → *ErrInvalidOpenAIServiceTier (handler maps to HTTP 400),
  1395. // matching OpenAI's enum validation semantics
  1396. func ValidateOpenAIServiceTierField(body []byte) (string, error) {
  1397. tierResult := gjson.GetBytes(body, "service_tier")
  1398. if !tierResult.Exists() || tierResult.Type == gjson.Null {
  1399. return "", nil
  1400. }
  1401. if tierResult.Type != gjson.String {
  1402. return "", &ErrInvalidOpenAIServiceTier{Value: "<non-string>"}
  1403. }
  1404. raw := strings.TrimSpace(tierResult.String())
  1405. if raw == "" {
  1406. return "", &ErrInvalidOpenAIServiceTier{Value: raw}
  1407. }
  1408. norm := normalizedOpenAIServiceTierValue(raw)
  1409. if norm == "" {
  1410. return "", &ErrInvalidOpenAIServiceTier{Value: boundInvalidOpenAIServiceTierValue(raw)}
  1411. }
  1412. return norm, nil
  1413. }
  1414. // OpenAIFastBlockedError indicates a request was rejected by the OpenAI fast
  1415. // policy (action=block). Mirrors BetaBlockedError on the Claude side.
  1416. type OpenAIFastBlockedError struct {
  1417. Message string
  1418. }
  1419. func (e *OpenAIFastBlockedError) Error() string { return e.Message }
  1420. // evaluateOpenAIFastPolicy returns the action and error message that should be
  1421. // applied for a request with the given account/model/service_tier. When the
  1422. // policy service is unavailable or no rule matches, it returns
  1423. // (BetaPolicyActionPass, "") so callers can short-circuit safely.
  1424. //
  1425. // Matching rules:
  1426. // - Scope filters by account type (all / oauth / apikey / bedrock)
  1427. // - UserIDs, when present, filters by the trusted Sub2API user that owns the API key
  1428. // - ServiceTier must be empty (= any), "all", or equal the normalized tier
  1429. // - ModelWhitelist narrows the rule to specific models; FallbackAction
  1430. // handles the non-matching case (default: pass)
  1431. // - User-specific rules take precedence over global rules; each group keeps
  1432. // the configured first-match order
  1433. //
  1434. // 与 Claude BetaPolicy 的差异(保留首条匹配 short-circuit):
  1435. // - BetaPolicy 处理的是 anthropic-beta header 中的 token 集合,不同
  1436. // 规则可能针对不同 token,filter 需要累加成 set;block 则 first-match。
  1437. // - OpenAI fast policy 操作的是单个字段 service_tier:filter 即删字段,
  1438. // 没有可累加的对象。一次请求只携带一个 service_tier,规则的 tier
  1439. // 维度天然互斥;同一 (scope, tier) 下若多条规则的 model whitelist
  1440. // 发生重叠,admin 可通过规则顺序明确意图。因此采用 first-match 而
  1441. // 非 BetaPolicy 那样的"block 覆盖 filter 覆盖 pass"语义。
  1442. func (s *OpenAIGatewayService) evaluateOpenAIFastPolicy(ctx context.Context, account *Account, model, serviceTier string) (action, errMsg string) {
  1443. if s == nil || s.settingService == nil {
  1444. return BetaPolicyActionPass, ""
  1445. }
  1446. tier := strings.ToLower(strings.TrimSpace(serviceTier))
  1447. if tier == "" {
  1448. return BetaPolicyActionPass, ""
  1449. }
  1450. settings := openAIFastPolicySettingsFromContext(ctx)
  1451. if settings == nil {
  1452. fetched, err := s.settingService.GetOpenAIFastPolicySettings(ctx)
  1453. if err != nil || fetched == nil {
  1454. return BetaPolicyActionPass, ""
  1455. }
  1456. settings = fetched
  1457. }
  1458. return evaluateOpenAIFastPolicyWithSettings(settings, openAIFastPolicyUserID(ctx), account, model, tier)
  1459. }
  1460. // evaluateOpenAIFastPolicyWithSettings is the pure-function core extracted so
  1461. // long-lived sessions (e.g. WS) can prefetch settings once and avoid hitting
  1462. // the settingService on every frame. See WSSession entry and
  1463. // openAIFastPolicySettingsFromContext for the caching glue.
  1464. func evaluateOpenAIFastPolicyWithSettings(settings *OpenAIFastPolicySettings, userID int64, account *Account, model, tier string) (action, errMsg string) {
  1465. if settings == nil {
  1466. return BetaPolicyActionPass, ""
  1467. }
  1468. isOAuth := account != nil && account.IsOAuth()
  1469. isBedrock := account != nil && account.IsBedrock()
  1470. // 用户专属规则先于全局规则。规则组内仍按配置顺序首条命中,允许
  1471. // 管理员为某位用户配置例外,而不被先出现的全局规则覆盖。
  1472. for _, userScoped := range []bool{true, false} {
  1473. for _, rule := range settings.Rules {
  1474. if (len(rule.UserIDs) > 0) != userScoped || !openAIFastPolicyUserMatches(rule.UserIDs, userID) {
  1475. continue
  1476. }
  1477. if !betaPolicyScopeMatches(rule.Scope, isOAuth, isBedrock) {
  1478. continue
  1479. }
  1480. ruleTier := strings.ToLower(strings.TrimSpace(rule.ServiceTier))
  1481. if ruleTier != "" && ruleTier != OpenAIFastTierAny && ruleTier != tier {
  1482. continue
  1483. }
  1484. eff := BetaPolicyRule{
  1485. Action: rule.Action,
  1486. ErrorMessage: rule.ErrorMessage,
  1487. ModelWhitelist: rule.ModelWhitelist,
  1488. FallbackAction: rule.FallbackAction,
  1489. FallbackErrorMessage: rule.FallbackErrorMessage,
  1490. }
  1491. return resolveRuleAction(eff, model)
  1492. }
  1493. }
  1494. return BetaPolicyActionPass, ""
  1495. }
  1496. func openAIFastPolicyUserID(ctx context.Context) int64 {
  1497. if ctx == nil {
  1498. return 0
  1499. }
  1500. userID, _ := ctx.Value(ctxkey.UserID).(int64)
  1501. if userID <= 0 {
  1502. return 0
  1503. }
  1504. return userID
  1505. }
  1506. func openAIFastPolicyUserMatches(ruleUserIDs []int64, userID int64) bool {
  1507. if len(ruleUserIDs) == 0 {
  1508. return true
  1509. }
  1510. for _, ruleUserID := range ruleUserIDs {
  1511. if ruleUserID == userID {
  1512. return true
  1513. }
  1514. }
  1515. return false
  1516. }
  1517. // openAIFastPolicyCtxKey 是 context 中预取的 OpenAIFastPolicySettings 缓存
  1518. // 键,仅用于 WebSocket 长会话内多帧复用同一份策略快照,避免每帧 DB 命中。
  1519. //
  1520. // Trade-off:策略变更不会影响当前 WS session(只影响新 session)。这是
  1521. // 有意为之 —— 对长会话来说,"策略一致性"比"立刻生效"更重要,且 Claude
  1522. // BetaPolicy 的 gin.Context 缓存也是同样取舍。需要 hot-reload 时管理员
  1523. // 可以通过踢断 session 强制刷新。
  1524. type openAIFastPolicyCtxKeyType struct{}
  1525. var openAIFastPolicyCtxKey = openAIFastPolicyCtxKeyType{}
  1526. // withOpenAIFastPolicyContext 将一份 settings 快照绑定到 context,供该 ctx
  1527. // 衍生 goroutine 中的 evaluateOpenAIFastPolicy 复用。
  1528. func withOpenAIFastPolicyContext(ctx context.Context, settings *OpenAIFastPolicySettings) context.Context {
  1529. if ctx == nil || settings == nil {
  1530. return ctx
  1531. }
  1532. return context.WithValue(ctx, openAIFastPolicyCtxKey, settings)
  1533. }
  1534. func openAIFastPolicySettingsFromContext(ctx context.Context) *OpenAIFastPolicySettings {
  1535. if ctx == nil {
  1536. return nil
  1537. }
  1538. if v, ok := ctx.Value(openAIFastPolicyCtxKey).(*OpenAIFastPolicySettings); ok {
  1539. return v
  1540. }
  1541. return nil
  1542. }
  1543. func openAIGroupForcesFast(ctx context.Context, account *Account) bool {
  1544. if ctx == nil || account == nil || account.Platform != PlatformOpenAI {
  1545. return false
  1546. }
  1547. group, _ := ctx.Value(ctxkey.Group).(*Group)
  1548. return IsGroupContextValid(group) && groupSupportsOpenAIFast(group.Platform) && group.ForceOpenAIFast
  1549. }
  1550. // applyOpenAIFastPolicyToBody applies the OpenAI fast policy to a raw request
  1551. // body. When action=filter it removes the service_tier field; when
  1552. // action=block it returns (body, *OpenAIFastBlockedError). On pass it
  1553. // normalizes the service_tier value (e.g. client alias "fast" → "priority").
  1554. // action=force_priority rewrites any matched known tier to "priority". Before
  1555. // the global policy is evaluated, a trusted request Group with
  1556. // ForceOpenAIFast enabled unconditionally sets service_tier to "priority".
  1557. // The global policy remains authoritative and may still pass, filter, or block
  1558. // that final value.
  1559. //
  1560. // Rationale for normalize-on-pass: chat-completions / messages 入口在调用本
  1561. // 函数之前已经通过 normalizeResponsesBodyServiceTier 把 service_tier 归一化
  1562. // 到了上游可识别值;passthrough(OpenAI 自动透传) / native /responses 等
  1563. // 入口没有这一前置步骤,pass 路径下若不在此处归一化,"fast" 就会被原样
  1564. // 透传到 OpenAI 上游导致 400/拒绝。把归一化收敛到本函数,所有入口行为一致。
  1565. func (s *OpenAIGatewayService) applyOpenAIFastPolicyToBody(ctx context.Context, account *Account, model string, body []byte) ([]byte, error) {
  1566. if len(body) == 0 {
  1567. return body, nil
  1568. }
  1569. if openAIGroupForcesFast(ctx, account) {
  1570. updated, err := sjson.SetBytes(body, "service_tier", OpenAIFastTierPriority)
  1571. if err != nil {
  1572. return body, fmt.Errorf("force group service_tier priority on body: %w", err)
  1573. }
  1574. body = updated
  1575. }
  1576. rawTier := gjson.GetBytes(body, "service_tier").String()
  1577. if rawTier == "" {
  1578. return body, nil
  1579. }
  1580. normTier := normalizedOpenAIServiceTierValue(rawTier)
  1581. if normTier == "" {
  1582. return body, nil
  1583. }
  1584. action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier)
  1585. switch action {
  1586. case BetaPolicyActionBlock:
  1587. msg := errMsg
  1588. if msg == "" {
  1589. msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model)
  1590. }
  1591. return body, &OpenAIFastBlockedError{Message: msg}
  1592. case BetaPolicyActionFilter:
  1593. trimmed, err := sjson.DeleteBytes(body, "service_tier")
  1594. if err != nil {
  1595. return body, fmt.Errorf("strip service_tier from body: %w", err)
  1596. }
  1597. return trimmed, nil
  1598. case OpenAIFastPolicyActionForcePriority:
  1599. updated, err := sjson.SetBytes(body, "service_tier", OpenAIFastTierPriority)
  1600. if err != nil {
  1601. return body, fmt.Errorf("force service_tier priority on body: %w", err)
  1602. }
  1603. return updated, nil
  1604. default:
  1605. // pass:把别名(如 "fast")写回为规范值("priority")。
  1606. if normTier == rawTier {
  1607. return body, nil
  1608. }
  1609. updated, err := sjson.SetBytes(body, "service_tier", normTier)
  1610. if err != nil {
  1611. return body, fmt.Errorf("normalize service_tier on pass: %w", err)
  1612. }
  1613. return updated, nil
  1614. }
  1615. }
  1616. // writeOpenAIFastPolicyBlockedResponse writes a 403 JSON response for a
  1617. // request blocked by the OpenAI fast policy.
  1618. func writeOpenAIFastPolicyBlockedResponse(c *gin.Context, err *OpenAIFastBlockedError) {
  1619. if c == nil || err == nil {
  1620. return
  1621. }
  1622. MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalPolicyDenied)
  1623. // body-signal compact 心跳可能已把响应头提交为 200(长排队后才进入
  1624. // Forward),此时以 response.failed 终止事件回传;未提交时先停拍再写
  1625. // JSON,保持原状态码语义(#3887)。
  1626. if StopOpenAICompactSSEKeepaliveCommitted(c) {
  1627. writeOpenAICompactSSEFailureMessage(c, http.StatusForbidden, "permission_error", err.Message)
  1628. return
  1629. }
  1630. c.JSON(http.StatusForbidden, gin.H{
  1631. "error": gin.H{
  1632. "type": "permission_error",
  1633. "message": err.Message,
  1634. },
  1635. })
  1636. }
  1637. // applyOpenAIFastPolicyToWSResponseCreate evaluates the OpenAI fast policy
  1638. // against a single client→upstream WebSocket frame whose top-level
  1639. // "type"=="response.create". It mirrors the HTTP-side
  1640. // applyOpenAIFastPolicyToBody contract but operates on a Realtime/Responses
  1641. // WS payload:
  1642. //
  1643. // - pass: keeps service_tier, normalizing aliases such as "fast" to "priority"
  1644. // - filter: returns a copy with top-level service_tier removed
  1645. // - force_priority: keeps service_tier and rewrites it to "priority"
  1646. // - block: returns (frame, *OpenAIFastBlockedError)
  1647. // - Group ForceOpenAIFast: sets priority first, then applies the global rule
  1648. //
  1649. // Only frames whose "type" field strictly equals "response.create" are
  1650. // inspected/mutated. Any other frame type — including the empty string —
  1651. // passes through untouched. The OpenAI Realtime client-event spec requires
  1652. // "type" to be set, so an empty type is treated as a malformed frame we do
  1653. // not police; the upstream is the source of truth for rejecting it.
  1654. //
  1655. // service_tier lives at the top level of response.create — same as the
  1656. // Responses HTTP body shape (see openai_gateway_chat_completions.go:304 +
  1657. // extractOpenAIServiceTierFromBody at line 5593, and the test fixture at
  1658. // openai_ws_forwarder_ingress_session_test.go:402). We therefore only need
  1659. // to inspect / strip the top-level field; there is no nested form in the
  1660. // schema today.
  1661. //
  1662. // The caller is responsible for choosing the upstream model passed in —
  1663. // this helper does not re-derive it.
  1664. func (s *OpenAIGatewayService) applyOpenAIFastPolicyToWSResponseCreate(
  1665. ctx context.Context,
  1666. account *Account,
  1667. model string,
  1668. frame []byte,
  1669. ) ([]byte, *OpenAIFastBlockedError, error) {
  1670. if len(frame) == 0 {
  1671. return frame, nil, nil
  1672. }
  1673. if !gjson.ValidBytes(frame) {
  1674. return frame, nil, nil
  1675. }
  1676. frameType := strings.TrimSpace(gjson.GetBytes(frame, "type").String())
  1677. // Strict match: only response.create is policy-checked. Empty / other
  1678. // types pass through untouched so we never accidentally strip fields
  1679. // from response.cancel, conversation.item.create, or any future
  1680. // client-event the spec adds. The Realtime spec requires "type" on
  1681. // every client event, so an empty type is malformed input — let the
  1682. // upstream reject it rather than guessing at our layer.
  1683. if frameType != "response.create" {
  1684. return frame, nil, nil
  1685. }
  1686. if openAIGroupForcesFast(ctx, account) {
  1687. updated, err := sjson.SetBytes(frame, "service_tier", OpenAIFastTierPriority)
  1688. if err != nil {
  1689. return frame, nil, fmt.Errorf("force group service_tier priority in ws frame: %w", err)
  1690. }
  1691. frame = updated
  1692. }
  1693. rawTier := gjson.GetBytes(frame, "service_tier").String()
  1694. if rawTier == "" {
  1695. return frame, nil, nil
  1696. }
  1697. normTier := normalizedOpenAIServiceTierValue(rawTier)
  1698. if normTier == "" {
  1699. return frame, nil, nil
  1700. }
  1701. action, errMsg := s.evaluateOpenAIFastPolicy(ctx, account, model, normTier)
  1702. switch action {
  1703. case BetaPolicyActionBlock:
  1704. msg := errMsg
  1705. if msg == "" {
  1706. msg = fmt.Sprintf("openai service_tier=%s is not allowed for model %s", normTier, model)
  1707. }
  1708. return frame, &OpenAIFastBlockedError{Message: msg}, nil
  1709. case BetaPolicyActionFilter:
  1710. trimmed, err := sjson.DeleteBytes(frame, "service_tier")
  1711. if err != nil {
  1712. return frame, nil, fmt.Errorf("strip service_tier from ws frame: %w", err)
  1713. }
  1714. return trimmed, nil, nil
  1715. case OpenAIFastPolicyActionForcePriority:
  1716. updated, err := sjson.SetBytes(frame, "service_tier", OpenAIFastTierPriority)
  1717. if err != nil {
  1718. return frame, nil, fmt.Errorf("force service_tier priority in ws frame: %w", err)
  1719. }
  1720. return updated, nil, nil
  1721. default:
  1722. if normTier == rawTier {
  1723. return frame, nil, nil
  1724. }
  1725. updated, err := sjson.SetBytes(frame, "service_tier", normTier)
  1726. if err != nil {
  1727. return frame, nil, fmt.Errorf("normalize service_tier in ws frame: %w", err)
  1728. }
  1729. return updated, nil, nil
  1730. }
  1731. }
  1732. // newOpenAIFastPolicyWSEventID returns a Realtime-style event_id for a
  1733. // server-emitted error event. Matches the loose "evt_<rand>" convention used
  1734. // by upstream Realtime servers; the exact value is not load-bearing and is
  1735. // only required for client-side log correlation. We reuse the existing
  1736. // google/uuid dependency rather than pulling a new one.
  1737. func newOpenAIFastPolicyWSEventID() string {
  1738. id, err := uuid.NewRandom()
  1739. if err != nil {
  1740. // Extremely unlikely; fall back to a fixed prefix so the field is
  1741. // still non-empty and the schema stays self-consistent.
  1742. return "evt_openai_fast_policy"
  1743. }
  1744. // Strip dashes so it visually matches "evt_<hex>" rather than UUID v4
  1745. // canonical form, mirroring what real Realtime traces look like.
  1746. return "evt_" + strings.ReplaceAll(id.String(), "-", "")
  1747. }
  1748. // buildOpenAIFastPolicyBlockedWSEvent renders an OpenAI Realtime/Responses
  1749. // style "error" event payload for a request blocked by the OpenAI fast
  1750. // policy. The shape mirrors Realtime error events as observed in upstream
  1751. // traces and per the spec's server "error" event:
  1752. //
  1753. // {
  1754. // "event_id": "evt_<random>",
  1755. // "type": "error",
  1756. // "error": {
  1757. // "type": "invalid_request_error",
  1758. // "code": "policy_violation",
  1759. // "message": "..."
  1760. // }
  1761. // }
  1762. //
  1763. // event_id lets clients correlate the rejection in their logs; "code" gives
  1764. // programmatic clients a stable identifier (HTTP-side equivalent is the
  1765. // 403 permission_error JSON body).
  1766. func buildOpenAIFastPolicyBlockedWSEvent(err *OpenAIFastBlockedError) []byte {
  1767. if err == nil {
  1768. return nil
  1769. }
  1770. eventID := newOpenAIFastPolicyWSEventID()
  1771. payload, mErr := json.Marshal(map[string]any{
  1772. "event_id": eventID,
  1773. "type": "error",
  1774. "error": map[string]any{
  1775. "type": "invalid_request_error",
  1776. "code": "policy_violation",
  1777. "message": err.Message,
  1778. },
  1779. })
  1780. if mErr != nil {
  1781. // Fallback to a minimal hand-rolled payload; Marshal of the literal
  1782. // shape above should never fail in practice.
  1783. return []byte(`{"event_id":"` + eventID + `","type":"error","error":{"type":"invalid_request_error","code":"policy_violation","message":"openai fast policy blocked this request"}}`)
  1784. }
  1785. return payload
  1786. }
  1787. func openAIRequestBodyMayContainImageInput(body []byte) bool {
  1788. if len(body) == 0 {
  1789. return false
  1790. }
  1791. input := gjson.GetBytes(body, "input")
  1792. messages := gjson.GetBytes(body, "messages.#-1")
  1793. return openAIJSONValueMayContainImageInput(input) || openAIJSONValueMayContainImageInput(messages)
  1794. }
  1795. func openAIJSONValueMayContainImageInput(value gjson.Result) bool {
  1796. if !value.Exists() {
  1797. return false
  1798. }
  1799. if value.IsArray() {
  1800. found := false
  1801. value.ForEach(func(_, item gjson.Result) bool {
  1802. if openAIJSONValueMayContainImageInput(item) {
  1803. found = true
  1804. return false
  1805. }
  1806. return true
  1807. })
  1808. return found
  1809. }
  1810. if value.IsObject() {
  1811. if strings.TrimSpace(value.Get("type").String()) == "input_image" || value.Get("image_url").Exists() {
  1812. return true
  1813. }
  1814. return openAIJSONValueMayContainImageInput(value.Get("content"))
  1815. }
  1816. return false
  1817. }
  1818. func openAIRequestBodyMayContainEmptyBase64InputImage(body []byte) bool {
  1819. if len(body) == 0 || !openAIRequestBodyMayContainInputImageToken(body) {
  1820. return false
  1821. }
  1822. input := gjson.GetBytes(body, "input")
  1823. if !input.Exists() {
  1824. return false
  1825. }
  1826. return openAIJSONValueMayContainEmptyBase64InputImage(input)
  1827. }
  1828. func openAIRequestBodyMayContainInputImageToken(body []byte) bool {
  1829. if bytes.Contains(body, []byte("input_image")) {
  1830. return true
  1831. }
  1832. // JSON 字符串任意字符都可能被 unicode escape,遇到 \u 时交给 gjson 解码后的结构扫描兜底。
  1833. return bytes.Contains(body, []byte("\\u"))
  1834. }
  1835. func openAIJSONValueMayContainEmptyBase64InputImage(value gjson.Result) bool {
  1836. if !value.Exists() {
  1837. return false
  1838. }
  1839. if value.IsArray() {
  1840. found := false
  1841. value.ForEach(func(_, item gjson.Result) bool {
  1842. if openAIJSONValueMayContainEmptyBase64InputImage(item) {
  1843. found = true
  1844. return false
  1845. }
  1846. return true
  1847. })
  1848. return found
  1849. }
  1850. if value.IsObject() {
  1851. if strings.TrimSpace(value.Get("type").String()) == "input_image" && isEmptyBase64DataURI(value.Get("image_url").String()) {
  1852. return true
  1853. }
  1854. return openAIJSONValueMayContainEmptyBase64InputImage(value.Get("content"))
  1855. }
  1856. return false
  1857. }
  1858. func sanitizeEmptyBase64InputImagesInOpenAIBody(body []byte) ([]byte, bool, error) {
  1859. if !openAIRequestBodyMayContainEmptyBase64InputImage(body) {
  1860. return body, false, nil
  1861. }
  1862. var reqBody map[string]any
  1863. if err := json.Unmarshal(body, &reqBody); err != nil {
  1864. return body, false, fmt.Errorf("sanitize request body: %w", err)
  1865. }
  1866. if !sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody) {
  1867. return body, false, nil
  1868. }
  1869. normalized, err := marshalOpenAIUpstreamJSON(reqBody)
  1870. if err != nil {
  1871. return body, false, fmt.Errorf("serialize sanitized request body: %w", err)
  1872. }
  1873. return normalized, true, nil
  1874. }
  1875. func sanitizeEmptyBase64InputImagesInOpenAIRequestBodyMap(reqBody map[string]any) bool {
  1876. if reqBody == nil {
  1877. return false
  1878. }
  1879. input, ok := reqBody["input"]
  1880. if !ok {
  1881. return false
  1882. }
  1883. normalizedInput, changed := sanitizeEmptyBase64InputImagesInOpenAIInput(input)
  1884. if !changed {
  1885. return false
  1886. }
  1887. reqBody["input"] = normalizedInput
  1888. return true
  1889. }
  1890. func sanitizeEmptyBase64InputImagesInOpenAIInput(input any) (any, bool) {
  1891. items, ok := input.([]any)
  1892. if !ok {
  1893. return input, false
  1894. }
  1895. normalizedItems := make([]any, 0, len(items))
  1896. changed := false
  1897. for _, item := range items {
  1898. itemMap, ok := item.(map[string]any)
  1899. if !ok {
  1900. normalizedItems = append(normalizedItems, item)
  1901. continue
  1902. }
  1903. if shouldDropEmptyBase64InputImagePart(itemMap) {
  1904. changed = true
  1905. continue
  1906. }
  1907. content, ok := itemMap["content"]
  1908. if !ok {
  1909. normalizedItems = append(normalizedItems, itemMap)
  1910. continue
  1911. }
  1912. parts, ok := content.([]any)
  1913. if !ok {
  1914. normalizedItems = append(normalizedItems, itemMap)
  1915. continue
  1916. }
  1917. normalizedParts := make([]any, 0, len(parts))
  1918. itemChanged := false
  1919. for _, part := range parts {
  1920. if shouldDropEmptyBase64InputImagePart(part) {
  1921. changed = true
  1922. itemChanged = true
  1923. continue
  1924. }
  1925. normalizedParts = append(normalizedParts, part)
  1926. }
  1927. if itemChanged {
  1928. if len(normalizedParts) == 0 {
  1929. continue
  1930. }
  1931. itemMap["content"] = normalizedParts
  1932. }
  1933. normalizedItems = append(normalizedItems, itemMap)
  1934. }
  1935. if !changed {
  1936. return input, false
  1937. }
  1938. return normalizedItems, true
  1939. }
  1940. func shouldDropEmptyBase64InputImagePart(part any) bool {
  1941. partMap, ok := part.(map[string]any)
  1942. if !ok {
  1943. return false
  1944. }
  1945. typeValue, _ := partMap["type"].(string)
  1946. if strings.TrimSpace(typeValue) != "input_image" {
  1947. return false
  1948. }
  1949. imageURL, _ := partMap["image_url"].(string)
  1950. return isEmptyBase64DataURI(imageURL)
  1951. }
  1952. func isEmptyBase64DataURI(raw string) bool {
  1953. if !strings.HasPrefix(raw, "data:") {
  1954. return false
  1955. }
  1956. rest := strings.TrimPrefix(raw, "data:")
  1957. semicolonIdx := strings.Index(rest, ";")
  1958. if semicolonIdx < 0 {
  1959. return false
  1960. }
  1961. rest = rest[semicolonIdx+1:]
  1962. if !strings.HasPrefix(rest, "base64,") {
  1963. return false
  1964. }
  1965. return strings.TrimSpace(strings.TrimPrefix(rest, "base64,")) == ""
  1966. }
  1967. func getOpenAIRequestBodyMap(_ *gin.Context, body []byte) (map[string]any, error) {
  1968. var reqBody map[string]any
  1969. if err := decodeOpenAIJSONUseNumber(body, &reqBody); err != nil {
  1970. return nil, fmt.Errorf("parse request: %w", err)
  1971. }
  1972. return reqBody, nil
  1973. }
  1974. // extractOpenAIReasoningEffort 的模型候选语义同 extractOpenAIReasoningEffortFromBody。
  1975. func extractOpenAIReasoningEffort(reqBody map[string]any, modelCandidates ...string) *string {
  1976. if value, present := getOpenAIReasoningEffortFromReqBody(reqBody, firstNonEmpty(modelCandidates...)); present {
  1977. if value == "" {
  1978. return nil
  1979. }
  1980. return &value
  1981. }
  1982. value := deriveOpenAIReasoningEffortFromModelCandidates(modelCandidates)
  1983. if value == "" {
  1984. return nil
  1985. }
  1986. return &value
  1987. }
  1988. func CanonicalRequestedReasoningEffortFromReqBody(reqBody map[string]any, modelCandidates ...string) *string {
  1989. if reqBody == nil {
  1990. return CanonicalRequestedReasoningEffort(nil, modelCandidates...)
  1991. }
  1992. raw := ""
  1993. if reasoning, ok := reqBody["reasoning"].(map[string]any); ok {
  1994. if effort, ok := reasoning["effort"].(string); ok {
  1995. raw = strings.TrimSpace(effort)
  1996. }
  1997. }
  1998. if raw == "" {
  1999. if effort, ok := reqBody["reasoning_effort"].(string); ok {
  2000. raw = strings.TrimSpace(effort)
  2001. }
  2002. }
  2003. if raw != "" {
  2004. canonical := NormalizeMaxReasoningEffort(raw)
  2005. if canonical == "" {
  2006. return nil
  2007. }
  2008. return &canonical
  2009. }
  2010. return CanonicalRequestedReasoningEffort(nil, modelCandidates...)
  2011. }
  2012. func normalizeOpenAIReasoningEffort(raw string) string {
  2013. value := strings.ToLower(strings.TrimSpace(raw))
  2014. if value == "" {
  2015. return ""
  2016. }
  2017. // Normalize separators for "x-high"/"x_high" variants.
  2018. value = strings.NewReplacer("-", "", "_", "", " ", "").Replace(value)
  2019. switch value {
  2020. case "none", "minimal":
  2021. return ""
  2022. case "low", "medium", "high":
  2023. return value
  2024. case "xhigh", "extrahigh", "max":
  2025. return "xhigh"
  2026. default:
  2027. // Only store known effort levels for now to keep UI consistent.
  2028. return ""
  2029. }
  2030. }
  2031. func normalizeOpenAIReasoningEffortForModel(raw, model string) string {
  2032. if strings.EqualFold(strings.TrimSpace(raw), "max") && supportsOpenAIReasoningEffortMax(model) {
  2033. return "max"
  2034. }
  2035. return normalizeOpenAIReasoningEffort(raw)
  2036. }
  2037. // supportsOpenAIReasoningEffortMax reports model families whose upstream scale
  2038. // has a distinct max level. Other models keep the legacy max -> xhigh behavior.
  2039. func supportsOpenAIReasoningEffortMax(model string) bool {
  2040. if isOpenAIGPT6AstraModel(model) || isOpenAIGPT56Model(model) {
  2041. return true
  2042. }
  2043. normalized := strings.ToLower(lastOpenAIModelSegment(model))
  2044. normalized = strings.ReplaceAll(normalized, "_", "-")
  2045. switch {
  2046. case strings.HasPrefix(normalized, "deepseek-v4"):
  2047. return true
  2048. case strings.HasPrefix(normalized, "glm-"):
  2049. return true
  2050. case strings.HasPrefix(normalized, "kimi-"), strings.HasPrefix(normalized, "moonshot-"):
  2051. return true
  2052. case normalized == "k3" || strings.HasPrefix(normalized, "k3-"):
  2053. return true
  2054. default:
  2055. return false
  2056. }
  2057. }