openai_alpha_search.go 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "io"
  8. "net/http"
  9. "net/url"
  10. "strings"
  11. "time"
  12. "github.com/gin-gonic/gin"
  13. "github.com/tidwall/gjson"
  14. )
  15. const (
  16. chatgptCodexAlphaSearchURL = "https://chatgpt.com/backend-api/codex/alpha/search"
  17. openAIPlatformAlphaSearchURL = "https://api.openai.com/v1/alpha/search"
  18. )
  19. // ForwardAlphaSearch proxies Codex standalone web search without binding the
  20. // evolving alpha request or response schema.
  21. //
  22. // 返回值约定:仅当上游返回 2xx(一次真实成功的搜索)时返回非 nil 的
  23. // *OpenAIForwardResult(WebSearchCalls=1,供按次计费);上游错误被原样透传
  24. // 给客户端时返回 (nil, nil),不产生计费。
  25. func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
  26. if s == nil || c == nil || account == nil {
  27. return nil, fmt.Errorf("service, context, and account are required")
  28. }
  29. if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
  30. return nil, err
  31. }
  32. modelResult := gjson.GetBytes(body, "model")
  33. requestedModel := strings.TrimSpace(modelResult.String())
  34. if modelResult.Type != gjson.String || requestedModel == "" {
  35. return nil, fmt.Errorf("model is required")
  36. }
  37. upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(requestedModel))
  38. if upstreamModel != "" && upstreamModel != requestedModel {
  39. body = ReplaceModelInBody(body, upstreamModel)
  40. }
  41. sanitizedBody, err := sanitizeOpenAIAlphaSearchBody(body)
  42. if err != nil {
  43. return nil, fmt.Errorf("sanitize alpha search request body: %w", err)
  44. }
  45. body = sanitizedBody
  46. token, _, err := s.GetAccessToken(ctx, account)
  47. if err != nil {
  48. return nil, err
  49. }
  50. proxyURL := ""
  51. if account.ProxyID != nil && account.Proxy != nil {
  52. proxyURL = account.Proxy.URL()
  53. }
  54. if err := s.ensureOpenAIAlphaSearchAuthMetadata(ctx, account, token, proxyURL); err != nil {
  55. return nil, err
  56. }
  57. SetOpsUpstreamModel(c, upstreamModel)
  58. // Codex Personal Access Token(at-...)目前可访问 ChatGPT Codex
  59. // /responses,但会被 standalone /alpha/search 的 access enforcement
  60. // 拒绝为 no_matching_rule。对 PAT 账号使用等价的 hosted web_search
  61. // Responses 路径兜底,避免把可用账号误判为搜索不可用。
  62. if account.IsOpenAIPersonalAccessToken() {
  63. return s.forwardAlphaSearchViaResponsesWebSearch(ctx, c, account, body, token, proxyURL, requestedModel, upstreamModel)
  64. }
  65. req, err := s.buildOpenAIAlphaSearchRequest(ctx, c, account, body, token)
  66. if err != nil {
  67. return nil, err
  68. }
  69. upstreamStart := time.Now()
  70. resp, err := s.doOpenAIUpstream(req, proxyURL, account)
  71. SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
  72. if err != nil {
  73. return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
  74. }
  75. defer func() { _ = resp.Body.Close() }()
  76. respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
  77. if err != nil {
  78. return nil, fmt.Errorf("read alpha search response: %w", err)
  79. }
  80. if resp.StatusCode >= http.StatusBadRequest {
  81. upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
  82. if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMessage, respBody) ||
  83. isOpenAIAlphaSearchEndpointUnsupported(account, resp.StatusCode) {
  84. resp.Body = io.NopCloser(bytes.NewReader(respBody))
  85. // alpha/search 是独立的工具端点,单次 401 不能证明账号的模型调用
  86. // 凭据全局失效。若沿用通用 401 逻辑,PAT 会因没有 refresh_token
  87. // 被永久标记为 error;历史导入且缺少 auth_mode 标记的 at- token 也会
  88. // 漏过 PAT 类型判断。这里仍允许本次请求换号,但不修改任何账号状态;
  89. // 真正的凭据失效由普通 Responses 请求或 whoami 校验判定。
  90. shouldDisable := false
  91. if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) {
  92. shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, openAIAlphaSearchSchedulingModel(account, requestedModel))
  93. }
  94. retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
  95. if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests {
  96. return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMessage, shouldDisable, retryableOnSameAccount)
  97. }
  98. if isOpenAIHTTPUpstreamAccessStateError(resp.StatusCode, upstreamMessage, respBody) {
  99. return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMessage, retryableOnSameAccount)
  100. }
  101. return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
  102. }
  103. }
  104. if !account.IsShadow() {
  105. s.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, resp.Header)
  106. }
  107. writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
  108. contentType := resp.Header.Get("Content-Type")
  109. if contentType == "" {
  110. contentType = "application/json"
  111. }
  112. c.Data(resp.StatusCode, contentType, respBody)
  113. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
  114. // 非 2xx(错误/重定向)已原样透传给客户端:不是一次成功的搜索,不计费。
  115. return nil, nil
  116. }
  117. return &OpenAIForwardResult{
  118. RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")),
  119. UpstreamHeaders: resp.Header,
  120. Model: requestedModel,
  121. UpstreamModel: upstreamModel,
  122. Duration: time.Since(upstreamStart),
  123. WebSearchCalls: 1,
  124. }, nil
  125. }
  126. func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch(
  127. ctx context.Context,
  128. c *gin.Context,
  129. account *Account,
  130. alphaBody []byte,
  131. token string,
  132. proxyURL string,
  133. requestedModel string,
  134. upstreamModel string,
  135. ) (*OpenAIForwardResult, error) {
  136. if upstreamModel == "" {
  137. upstreamModel = requestedModel
  138. }
  139. responsesBody, err := buildOpenAIAlphaSearchResponsesWebSearchBody(alphaBody, upstreamModel)
  140. if err != nil {
  141. return nil, err
  142. }
  143. req, err := s.buildOpenAIAlphaSearchResponsesWebSearchRequest(ctx, c, account, alphaBody, responsesBody, token)
  144. if err != nil {
  145. return nil, err
  146. }
  147. SetActualOpenAIUpstreamEndpoint(c, "/v1/responses")
  148. upstreamStart := time.Now()
  149. resp, err := s.doOpenAIUpstream(req, proxyURL, account)
  150. SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
  151. if err != nil {
  152. return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
  153. }
  154. defer func() { _ = resp.Body.Close() }()
  155. respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
  156. if err != nil {
  157. return nil, fmt.Errorf("read alpha search responses fallback response: %w", err)
  158. }
  159. if resp.StatusCode >= http.StatusBadRequest {
  160. upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
  161. if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMessage, respBody) {
  162. resp.Body = io.NopCloser(bytes.NewReader(respBody))
  163. // 仍按 alpha/search 工具请求处理:PAT 的工具链路失败不能直接永久置错。
  164. shouldDisable := false
  165. if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) {
  166. shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, openAIAlphaSearchSchedulingModel(account, requestedModel))
  167. }
  168. retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
  169. if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests {
  170. return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMessage, shouldDisable, retryableOnSameAccount)
  171. }
  172. if isOpenAIHTTPUpstreamAccessStateError(resp.StatusCode, upstreamMessage, respBody) {
  173. return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMessage, retryableOnSameAccount)
  174. }
  175. return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
  176. }
  177. }
  178. if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
  179. writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
  180. contentType := resp.Header.Get("Content-Type")
  181. if contentType == "" {
  182. contentType = "application/json"
  183. }
  184. c.Data(resp.StatusCode, contentType, respBody)
  185. return nil, nil
  186. }
  187. if !account.IsShadow() {
  188. s.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, resp.Header)
  189. }
  190. alphaRespBody, err := openAIAlphaSearchResponseFromResponsesSSE(respBody)
  191. if err != nil {
  192. return nil, err
  193. }
  194. c.Data(http.StatusOK, "application/json", alphaRespBody)
  195. return &OpenAIForwardResult{
  196. RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")),
  197. UpstreamHeaders: resp.Header,
  198. Model: requestedModel,
  199. UpstreamModel: upstreamModel,
  200. UpstreamEndpoint: "/v1/responses",
  201. ResponseHeaders: resp.Header.Clone(),
  202. Duration: time.Since(upstreamStart),
  203. WebSearchCalls: 1,
  204. }, nil
  205. }
  206. func openAIAlphaSearchSchedulingModel(account *Account, requestedModel string) string {
  207. return canonicalOpenAIAccountSchedulingModel(account, requestedModel)
  208. }
  209. func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(ctx context.Context, c *gin.Context, account *Account, alphaBody []byte, body []byte, token string) (*http.Request, error) {
  210. req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatgptCodexURL, bytes.NewReader(body))
  211. if err != nil {
  212. return nil, err
  213. }
  214. req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
  215. authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
  216. if err != nil {
  217. return nil, fmt.Errorf("build openai authentication headers: %w", err)
  218. }
  219. for key, values := range authHeaders {
  220. for _, value := range values {
  221. req.Header.Add(key, value)
  222. }
  223. }
  224. req.Host = "chatgpt.com"
  225. if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
  226. return nil, fmt.Errorf("resolve chatgpt account headers: %w", err)
  227. }
  228. req.Header.Set("Content-Type", "application/json")
  229. req.Header.Set("Accept", "text/event-stream")
  230. req.Header.Set("OpenAI-Beta", "responses=experimental")
  231. if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" {
  232. req.Header.Set("X-Codex-Turn-Metadata", turnMetadata)
  233. }
  234. canonical := resolveCodexOutboundIdentity("")
  235. if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" {
  236. req.Header.Set("Version", version)
  237. } else {
  238. req.Header.Set("Version", canonical.version)
  239. }
  240. if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" {
  241. req.Header.Set("Originator", originator)
  242. } else {
  243. req.Header.Set("Originator", canonical.originator)
  244. }
  245. if customUA := account.GetOpenAIUserAgent(); customUA != "" {
  246. req.Header.Set("User-Agent", customUA)
  247. } else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" {
  248. req.Header.Set("User-Agent", userAgent)
  249. } else {
  250. req.Header.Set("User-Agent", canonical.userAgent)
  251. }
  252. if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
  253. req.Header.Set("User-Agent", canonical.userAgent)
  254. }
  255. apiKeyID := getAPIKeyIDFromContext(c)
  256. if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" {
  257. isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionID)
  258. req.Header.Set("Session_ID", isolated)
  259. req.Header.Set("Conversation_ID", isolated)
  260. }
  261. applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID)
  262. enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
  263. account.ApplyHeaderOverrides(req.Header)
  264. return req, nil
  265. }
  266. func buildOpenAIAlphaSearchResponsesWebSearchBody(alphaBody []byte, model string) ([]byte, error) {
  267. if strings.TrimSpace(model) == "" {
  268. return nil, fmt.Errorf("model is required")
  269. }
  270. tool := map[string]any{"type": "web_search"}
  271. if contextSize := strings.TrimSpace(gjson.GetBytes(alphaBody, "settings.search_context_size").String()); contextSize != "" {
  272. tool["search_context_size"] = contextSize
  273. }
  274. if userLocation := gjson.GetBytes(alphaBody, "settings.user_location"); userLocation.IsObject() {
  275. var loc map[string]any
  276. if err := json.Unmarshal([]byte(userLocation.Raw), &loc); err == nil && len(loc) > 0 {
  277. tool["user_location"] = loc
  278. }
  279. }
  280. payload := map[string]any{
  281. "model": model,
  282. "stream": true,
  283. "store": false,
  284. "input": []any{
  285. map[string]any{
  286. "role": "user",
  287. "content": []any{
  288. map[string]any{
  289. "type": "input_text",
  290. "text": openAIAlphaSearchResponsesWebSearchPrompt(alphaBody),
  291. },
  292. },
  293. },
  294. },
  295. "tools": []any{tool},
  296. }
  297. return json.Marshal(payload)
  298. }
  299. func openAIAlphaSearchResponsesWebSearchPrompt(alphaBody []byte) string {
  300. var b strings.Builder
  301. _, _ = b.WriteString("Execute this Codex standalone web.run request for another model.\n")
  302. _, _ = b.WriteString("Use the hosted web_search tool when web/current information is needed.\n")
  303. _, _ = b.WriteString("Return concise source-backed results. Include titles, URLs, dates, and direct answers when available.\n")
  304. if commands := strings.TrimSpace(gjson.GetBytes(alphaBody, "commands").Raw); commands != "" {
  305. _, _ = b.WriteString("\nCommands JSON:\n")
  306. _, _ = b.WriteString(truncateOpenAIAlphaSearchPromptJSON(commands, 12000))
  307. }
  308. if settings := strings.TrimSpace(gjson.GetBytes(alphaBody, "settings").Raw); settings != "" {
  309. _, _ = b.WriteString("\n\nSearch settings JSON:\n")
  310. _, _ = b.WriteString(truncateOpenAIAlphaSearchPromptJSON(settings, 4000))
  311. }
  312. if input := strings.TrimSpace(gjson.GetBytes(alphaBody, "input").Raw); input != "" {
  313. _, _ = b.WriteString("\n\nRecent conversation/input JSON:\n")
  314. _, _ = b.WriteString(truncateOpenAIAlphaSearchPromptJSON(input, 8000))
  315. }
  316. if b.Len() == 0 {
  317. return "Execute the requested web search and return concise source-backed results."
  318. }
  319. return b.String()
  320. }
  321. func truncateOpenAIAlphaSearchPromptJSON(value string, limit int) string {
  322. value = strings.TrimSpace(value)
  323. if limit <= 0 || len(value) <= limit {
  324. return value
  325. }
  326. return value[:limit] + "\n...<truncated>"
  327. }
  328. func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string) (*http.Request, error) {
  329. targetURL, err := s.openAIAlphaSearchURL(account)
  330. if err != nil {
  331. return nil, err
  332. }
  333. parsedURL, err := url.Parse(targetURL)
  334. if err != nil {
  335. return nil, fmt.Errorf("parse alpha search URL: %w", err)
  336. }
  337. if c != nil && c.Request != nil && c.Request.URL != nil {
  338. query := parsedURL.Query()
  339. for key, values := range c.Request.URL.Query() {
  340. for _, value := range values {
  341. query.Add(key, value)
  342. }
  343. }
  344. parsedURL.RawQuery = query.Encode()
  345. }
  346. req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsedURL.String(), bytes.NewReader(body))
  347. if err != nil {
  348. return nil, err
  349. }
  350. req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
  351. authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
  352. if err != nil {
  353. return nil, fmt.Errorf("build openai authentication headers: %w", err)
  354. }
  355. for key, values := range authHeaders {
  356. for _, value := range values {
  357. req.Header.Add(key, value)
  358. }
  359. }
  360. req.Header.Set("Content-Type", "application/json")
  361. req.Header.Set("Accept", "application/json")
  362. if account.Type == AccountTypeOAuth {
  363. req.Host = "chatgpt.com"
  364. if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
  365. return nil, fmt.Errorf("resolve chatgpt account headers: %w", err)
  366. }
  367. if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" {
  368. req.Header.Set("X-Codex-Turn-Metadata", turnMetadata)
  369. }
  370. applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
  371. canonical := resolveCodexOutboundIdentity("")
  372. if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" {
  373. req.Header.Set("Version", version)
  374. } else {
  375. req.Header.Set("Version", canonical.version)
  376. }
  377. if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" {
  378. req.Header.Set("Originator", originator)
  379. } else {
  380. req.Header.Set("Originator", canonical.originator)
  381. }
  382. if customUA := account.GetOpenAIUserAgent(); customUA != "" {
  383. req.Header.Set("User-Agent", customUA)
  384. } else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" {
  385. req.Header.Set("User-Agent", userAgent)
  386. } else {
  387. req.Header.Set("User-Agent", canonical.userAgent)
  388. }
  389. if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
  390. req.Header.Set("User-Agent", canonical.userAgent)
  391. }
  392. enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
  393. }
  394. account.ApplyHeaderOverrides(req.Header)
  395. stripOpenAIAlphaSearchResponsesHeaders(req.Header)
  396. return req, nil
  397. }
  398. // stripOpenAIAlphaSearchResponsesHeaders 让独立搜索请求与官方 Codex
  399. // SearchClient 的线协议保持一致。alpha/search 不是 /responses 的子请求:官方
  400. // 客户端仅在 Provider/Auth 基础头之外附加 x-codex-turn-metadata,不发送
  401. // OpenAI-Beta、会话隔离或 Responses Lite 状态头。originator 与 User-Agent
  402. // 属于官方默认客户端头,必须保留。
  403. //
  404. // alpha/search 使用专用构造器生成官方 SearchClient 的最小线协议形态;
  405. // 该函数作为最后一道防线,避免账号 header 覆写或后续改动重新带入
  406. // Responses 专用头,使 PAT 的 alpha/search 被上游按错误认证路径处理。
  407. func stripOpenAIAlphaSearchResponsesHeaders(headers http.Header) {
  408. if headers == nil {
  409. return
  410. }
  411. for _, key := range []string{
  412. "OpenAI-Beta",
  413. "Session_ID",
  414. "Conversation_ID",
  415. "X-Codex-Beta-Features",
  416. "X-Codex-Turn-State",
  417. responsesLiteHeaderKey,
  418. } {
  419. headers.Del(key)
  420. }
  421. }
  422. func openAIAlphaSearchInboundHeader(c *gin.Context, key string) string {
  423. if c == nil {
  424. return ""
  425. }
  426. return strings.TrimSpace(c.GetHeader(key))
  427. }
  428. var openAIAlphaSearchUnsupportedBodyFields = [...]string{
  429. // Codex alpha/search 是 SearchRequest 独立协议,不是 /responses 子请求。
  430. // 新版 Codex/第三方代理可能把 Responses 公共字段误带到搜索请求里;ChatGPT
  431. // alpha/search 会对这些字段返回 Unknown parameter(例如 prompt_cache_key)。
  432. "prompt_cache_key",
  433. "prompt_cache_retention",
  434. "store",
  435. }
  436. func sanitizeOpenAIAlphaSearchBody(body []byte) ([]byte, error) {
  437. if len(body) == 0 {
  438. return body, nil
  439. }
  440. var obj map[string]json.RawMessage
  441. if err := json.Unmarshal(body, &obj); err != nil || obj == nil {
  442. return body, nil
  443. }
  444. changed := false
  445. for _, field := range openAIAlphaSearchUnsupportedBodyFields {
  446. if _, ok := obj[field]; ok {
  447. delete(obj, field)
  448. changed = true
  449. }
  450. }
  451. if !changed {
  452. return body, nil
  453. }
  454. out, err := json.Marshal(obj)
  455. if err != nil {
  456. return nil, err
  457. }
  458. return out, nil
  459. }
  460. func (s *OpenAIGatewayService) ensureOpenAIAlphaSearchAuthMetadata(ctx context.Context, account *Account, token string, proxyURL string) error {
  461. if s == nil || account == nil || !account.IsOpenAIPersonalAccessToken() {
  462. return nil
  463. }
  464. if strings.TrimSpace(account.GetChatGPTAccountID()) != "" {
  465. return nil
  466. }
  467. var oauthService *OpenAIOAuthService
  468. if s.openAITokenProvider != nil {
  469. oauthService = s.openAITokenProvider.openAIOAuthService
  470. }
  471. if oauthService == nil {
  472. return nil
  473. }
  474. tokenInfo, err := oauthService.ValidateCodexPersonalAccessToken(ctx, token, proxyURL)
  475. if err != nil {
  476. return fmt.Errorf("validate Codex PAT metadata for alpha/search: %w", err)
  477. }
  478. credentials := shallowCopyMap(account.Credentials)
  479. for key, value := range oauthService.BuildAccountCredentials(tokenInfo) {
  480. credentials[key] = value
  481. }
  482. credentials = NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, credentials)
  483. account.Credentials = shallowCopyMap(credentials)
  484. if s.accountRepo != nil {
  485. if err := persistAccountCredentials(ctx, s.accountRepo, account, credentials); err != nil {
  486. return fmt.Errorf("persist Codex PAT metadata for alpha/search: %w", err)
  487. }
  488. }
  489. return nil
  490. }
  491. // isOpenAIAlphaSearchEndpointUnsupported 识别「API key 上游没有实现
  492. // /v1/alpha/search 端点」的响应。404/405 不在通用 failover 状态集里(模型
  493. // 调用中的 404 通常是用户请求问题),但对这个独立工具端点而言,它几乎只
  494. // 意味着所选上游(官方平台或第三方中转)不提供该端点——应换号重试,而
  495. // 不是把 404 透传给客户端,否则混合分组里 OAuth 账号明明可以承接搜索,
  496. // 请求却可能死在先被选中的 API key 账号上。
  497. func isOpenAIAlphaSearchEndpointUnsupported(account *Account, statusCode int) bool {
  498. if account == nil || account.Type != AccountTypeAPIKey {
  499. return false
  500. }
  501. return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed
  502. }
  503. func shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(statusCode int) bool {
  504. switch statusCode {
  505. case http.StatusUnauthorized, http.StatusNotFound, http.StatusMethodNotAllowed:
  506. // 401:工具端点的 access enforcement 不代表凭据全局失效;
  507. // 404/405:端点不存在只说明该上游不支持独立搜索,账号本身健康。
  508. // 两类都只换号,不写账号错误状态。
  509. return false
  510. default:
  511. return true
  512. }
  513. }
  514. func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) {
  515. output, results := parseOpenAIResponsesSSEForAlphaSearch(body)
  516. resp := map[string]any{
  517. "output": output,
  518. }
  519. if len(results) > 0 {
  520. resp["results"] = results
  521. }
  522. return json.Marshal(resp)
  523. }
  524. func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any) {
  525. text := strings.ReplaceAll(string(body), "\r\n", "\n")
  526. var output strings.Builder
  527. var completedResponse any
  528. results := make([]any, 0)
  529. seenURLs := make(map[string]struct{})
  530. for _, block := range strings.Split(text, "\n\n") {
  531. data := openAIAlphaSearchSSEData(block)
  532. if data == "" || data == "[DONE]" {
  533. continue
  534. }
  535. var event map[string]any
  536. if err := json.Unmarshal([]byte(data), &event); err != nil {
  537. continue
  538. }
  539. if delta, _ := event["delta"].(string); delta != "" && event["type"] == "response.output_text.delta" {
  540. _, _ = output.WriteString(delta)
  541. }
  542. if event["type"] == "response.completed" {
  543. completedResponse = event["response"]
  544. }
  545. collectOpenAIAlphaSearchURLCitations(event, &results, seenURLs)
  546. }
  547. out := output.String()
  548. if strings.TrimSpace(out) == "" && completedResponse != nil {
  549. out = extractOpenAIResponsesCompletedText(completedResponse)
  550. collectOpenAIAlphaSearchURLCitations(completedResponse, &results, seenURLs)
  551. }
  552. return out, results
  553. }
  554. func openAIAlphaSearchSSEData(block string) string {
  555. var lines []string
  556. for _, line := range strings.Split(block, "\n") {
  557. line = strings.TrimRight(line, "\r")
  558. if !strings.HasPrefix(line, "data:") {
  559. continue
  560. }
  561. lines = append(lines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
  562. }
  563. return strings.TrimSpace(strings.Join(lines, "\n"))
  564. }
  565. func extractOpenAIResponsesCompletedText(response any) string {
  566. resp, ok := response.(map[string]any)
  567. if !ok {
  568. return ""
  569. }
  570. outputItems, _ := resp["output"].([]any)
  571. var b strings.Builder
  572. for _, item := range outputItems {
  573. itemMap, ok := item.(map[string]any)
  574. if !ok || itemMap["type"] != "message" {
  575. continue
  576. }
  577. contentItems, _ := itemMap["content"].([]any)
  578. for _, content := range contentItems {
  579. contentMap, ok := content.(map[string]any)
  580. if !ok {
  581. continue
  582. }
  583. if contentMap["type"] == "output_text" {
  584. if text, _ := contentMap["text"].(string); text != "" {
  585. _, _ = b.WriteString(text)
  586. }
  587. }
  588. }
  589. }
  590. return b.String()
  591. }
  592. func collectOpenAIAlphaSearchURLCitations(value any, results *[]any, seen map[string]struct{}) {
  593. switch typed := value.(type) {
  594. case map[string]any:
  595. if typed["type"] == "url_citation" {
  596. if urlValue, _ := typed["url"].(string); strings.TrimSpace(urlValue) != "" {
  597. urlValue = strings.TrimSpace(urlValue)
  598. if _, exists := seen[urlValue]; !exists {
  599. seen[urlValue] = struct{}{}
  600. result := map[string]any{
  601. "type": "text_result",
  602. "ref_id": fmt.Sprintf("turn0search%d", len(*results)),
  603. "url": urlValue,
  604. }
  605. if title, _ := typed["title"].(string); strings.TrimSpace(title) != "" {
  606. result["title"] = strings.TrimSpace(title)
  607. }
  608. *results = append(*results, result)
  609. }
  610. }
  611. }
  612. for _, child := range typed {
  613. collectOpenAIAlphaSearchURLCitations(child, results, seen)
  614. }
  615. case []any:
  616. for _, child := range typed {
  617. collectOpenAIAlphaSearchURLCitations(child, results, seen)
  618. }
  619. }
  620. }
  621. func (s *OpenAIGatewayService) openAIAlphaSearchURL(account *Account) (string, error) {
  622. if account == nil {
  623. return "", fmt.Errorf("account is required")
  624. }
  625. switch account.Type {
  626. case AccountTypeOAuth, AccountTypeSetupToken:
  627. return chatgptCodexAlphaSearchURL, nil
  628. case AccountTypeAPIKey:
  629. baseURL := account.GetOpenAIBaseURL()
  630. if baseURL == "" {
  631. return openAIPlatformAlphaSearchURL, nil
  632. }
  633. validatedURL, err := s.validateUpstreamBaseURL(baseURL)
  634. if err != nil {
  635. return "", err
  636. }
  637. return buildOpenAIEndpointURL(validatedURL, "/v1/alpha/search"), nil
  638. default:
  639. return "", fmt.Errorf("unsupported OpenAI account type: %s", account.Type)
  640. }
  641. }