| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
- "github.com/gin-gonic/gin"
- "github.com/tidwall/gjson"
- )
- const (
- chatgptCodexAlphaSearchURL = "https://chatgpt.com/backend-api/codex/alpha/search"
- openAIPlatformAlphaSearchURL = "https://api.openai.com/v1/alpha/search"
- )
- // ForwardAlphaSearch proxies Codex standalone web search without binding the
- // evolving alpha request or response schema.
- //
- // 返回值约定:仅当上游返回 2xx(一次真实成功的搜索)时返回非 nil 的
- // *OpenAIForwardResult(WebSearchCalls=1,供按次计费);上游错误被原样透传
- // 给客户端时返回 (nil, nil),不产生计费。
- func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
- if s == nil || c == nil || account == nil {
- return nil, fmt.Errorf("service, context, and account are required")
- }
- if _, err := s.prepareCodexAccountIdentitySource(ctx, c, account); err != nil {
- return nil, err
- }
- modelResult := gjson.GetBytes(body, "model")
- requestedModel := strings.TrimSpace(modelResult.String())
- if modelResult.Type != gjson.String || requestedModel == "" {
- return nil, fmt.Errorf("model is required")
- }
- upstreamModel := normalizeOpenAIModelForUpstream(account, account.GetMappedModel(requestedModel))
- if upstreamModel != "" && upstreamModel != requestedModel {
- body = ReplaceModelInBody(body, upstreamModel)
- }
- sanitizedBody, err := sanitizeOpenAIAlphaSearchBody(body)
- if err != nil {
- return nil, fmt.Errorf("sanitize alpha search request body: %w", err)
- }
- body = sanitizedBody
- token, _, err := s.GetAccessToken(ctx, account)
- if err != nil {
- return nil, err
- }
- proxyURL := ""
- if account.ProxyID != nil && account.Proxy != nil {
- proxyURL = account.Proxy.URL()
- }
- if err := s.ensureOpenAIAlphaSearchAuthMetadata(ctx, account, token, proxyURL); err != nil {
- return nil, err
- }
- SetOpsUpstreamModel(c, upstreamModel)
- // Codex Personal Access Token(at-...)目前可访问 ChatGPT Codex
- // /responses,但会被 standalone /alpha/search 的 access enforcement
- // 拒绝为 no_matching_rule。对 PAT 账号使用等价的 hosted web_search
- // Responses 路径兜底,避免把可用账号误判为搜索不可用。
- if account.IsOpenAIPersonalAccessToken() {
- return s.forwardAlphaSearchViaResponsesWebSearch(ctx, c, account, body, token, proxyURL, requestedModel, upstreamModel)
- }
- req, err := s.buildOpenAIAlphaSearchRequest(ctx, c, account, body, token)
- if err != nil {
- return nil, err
- }
- upstreamStart := time.Now()
- resp, err := s.doOpenAIUpstream(req, proxyURL, account)
- SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
- if err != nil {
- return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
- }
- defer func() { _ = resp.Body.Close() }()
- respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
- if err != nil {
- return nil, fmt.Errorf("read alpha search response: %w", err)
- }
- if resp.StatusCode >= http.StatusBadRequest {
- upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
- if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMessage, respBody) ||
- isOpenAIAlphaSearchEndpointUnsupported(account, resp.StatusCode) {
- resp.Body = io.NopCloser(bytes.NewReader(respBody))
- // alpha/search 是独立的工具端点,单次 401 不能证明账号的模型调用
- // 凭据全局失效。若沿用通用 401 逻辑,PAT 会因没有 refresh_token
- // 被永久标记为 error;历史导入且缺少 auth_mode 标记的 at- token 也会
- // 漏过 PAT 类型判断。这里仍允许本次请求换号,但不修改任何账号状态;
- // 真正的凭据失效由普通 Responses 请求或 whoami 校验判定。
- shouldDisable := false
- if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) {
- shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, openAIAlphaSearchSchedulingModel(account, requestedModel))
- }
- retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
- if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests {
- return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMessage, shouldDisable, retryableOnSameAccount)
- }
- if isOpenAIHTTPUpstreamAccessStateError(resp.StatusCode, upstreamMessage, respBody) {
- return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMessage, retryableOnSameAccount)
- }
- return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
- }
- }
- if !account.IsShadow() {
- s.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, resp.Header)
- }
- writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
- contentType := resp.Header.Get("Content-Type")
- if contentType == "" {
- contentType = "application/json"
- }
- c.Data(resp.StatusCode, contentType, respBody)
- if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
- // 非 2xx(错误/重定向)已原样透传给客户端:不是一次成功的搜索,不计费。
- return nil, nil
- }
- return &OpenAIForwardResult{
- RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")),
- UpstreamHeaders: resp.Header,
- Model: requestedModel,
- UpstreamModel: upstreamModel,
- Duration: time.Since(upstreamStart),
- WebSearchCalls: 1,
- }, nil
- }
- func (s *OpenAIGatewayService) forwardAlphaSearchViaResponsesWebSearch(
- ctx context.Context,
- c *gin.Context,
- account *Account,
- alphaBody []byte,
- token string,
- proxyURL string,
- requestedModel string,
- upstreamModel string,
- ) (*OpenAIForwardResult, error) {
- if upstreamModel == "" {
- upstreamModel = requestedModel
- }
- responsesBody, err := buildOpenAIAlphaSearchResponsesWebSearchBody(alphaBody, upstreamModel)
- if err != nil {
- return nil, err
- }
- req, err := s.buildOpenAIAlphaSearchResponsesWebSearchRequest(ctx, c, account, alphaBody, responsesBody, token)
- if err != nil {
- return nil, err
- }
- SetActualOpenAIUpstreamEndpoint(c, "/v1/responses")
- upstreamStart := time.Now()
- resp, err := s.doOpenAIUpstream(req, proxyURL, account)
- SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(upstreamStart).Milliseconds())
- if err != nil {
- return nil, s.handleOpenAIUpstreamTransportError(ctx, c, account, err, true)
- }
- defer func() { _ = resp.Body.Close() }()
- respBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
- if err != nil {
- return nil, fmt.Errorf("read alpha search responses fallback response: %w", err)
- }
- if resp.StatusCode >= http.StatusBadRequest {
- upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
- if s.shouldFailoverOpenAIUpstreamResponse(account, resp.StatusCode, upstreamMessage, respBody) {
- resp.Body = io.NopCloser(bytes.NewReader(respBody))
- // 仍按 alpha/search 工具请求处理:PAT 的工具链路失败不能直接永久置错。
- shouldDisable := false
- if shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(resp.StatusCode) {
- shouldDisable = s.handleFailoverSideEffects(ctx, resp, account, respBody, openAIAlphaSearchSchedulingModel(account, requestedModel))
- }
- retryableOnSameAccount := !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode)
- if account.IsOpenAIOAuthLike() && resp.StatusCode == http.StatusTooManyRequests {
- return nil, s.newOpenAIAccountFailoverError(account, resp.StatusCode, resp.Header, respBody, upstreamMessage, shouldDisable, retryableOnSameAccount)
- }
- if isOpenAIHTTPUpstreamAccessStateError(resp.StatusCode, upstreamMessage, respBody) {
- return nil, newOpenAIUpstreamFailoverError(resp.StatusCode, resp.Header, respBody, upstreamMessage, retryableOnSameAccount)
- }
- return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: retryableOnSameAccount}
- }
- }
- if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
- writeOpenAIPassthroughResponseHeaders(c.Writer.Header(), resp.Header, s.responseHeaderFilter)
- contentType := resp.Header.Get("Content-Type")
- if contentType == "" {
- contentType = "application/json"
- }
- c.Data(resp.StatusCode, contentType, respBody)
- return nil, nil
- }
- if !account.IsShadow() {
- s.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, resp.Header)
- }
- alphaRespBody, err := openAIAlphaSearchResponseFromResponsesSSE(respBody)
- if err != nil {
- return nil, err
- }
- c.Data(http.StatusOK, "application/json", alphaRespBody)
- return &OpenAIForwardResult{
- RequestID: strings.TrimSpace(resp.Header.Get("x-request-id")),
- UpstreamHeaders: resp.Header,
- Model: requestedModel,
- UpstreamModel: upstreamModel,
- UpstreamEndpoint: "/v1/responses",
- ResponseHeaders: resp.Header.Clone(),
- Duration: time.Since(upstreamStart),
- WebSearchCalls: 1,
- }, nil
- }
- func openAIAlphaSearchSchedulingModel(account *Account, requestedModel string) string {
- return canonicalOpenAIAccountSchedulingModel(account, requestedModel)
- }
- func (s *OpenAIGatewayService) buildOpenAIAlphaSearchResponsesWebSearchRequest(ctx context.Context, c *gin.Context, account *Account, alphaBody []byte, body []byte, token string) (*http.Request, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, chatgptCodexURL, bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
- authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
- if err != nil {
- return nil, fmt.Errorf("build openai authentication headers: %w", err)
- }
- for key, values := range authHeaders {
- for _, value := range values {
- req.Header.Add(key, value)
- }
- }
- req.Host = "chatgpt.com"
- if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
- return nil, fmt.Errorf("resolve chatgpt account headers: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "text/event-stream")
- req.Header.Set("OpenAI-Beta", "responses=experimental")
- if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" {
- req.Header.Set("X-Codex-Turn-Metadata", turnMetadata)
- }
- canonical := resolveCodexOutboundIdentity("")
- if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" {
- req.Header.Set("Version", version)
- } else {
- req.Header.Set("Version", canonical.version)
- }
- if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" {
- req.Header.Set("Originator", originator)
- } else {
- req.Header.Set("Originator", canonical.originator)
- }
- if customUA := account.GetOpenAIUserAgent(); customUA != "" {
- req.Header.Set("User-Agent", customUA)
- } else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" {
- req.Header.Set("User-Agent", userAgent)
- } else {
- req.Header.Set("User-Agent", canonical.userAgent)
- }
- if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
- req.Header.Set("User-Agent", canonical.userAgent)
- }
- apiKeyID := getAPIKeyIDFromContext(c)
- if sessionID := strings.TrimSpace(gjson.GetBytes(alphaBody, "id").String()); sessionID != "" {
- isolated := isolateOpenAIUpstreamSessionID(apiKeyID, codexAccountIdentitySource(c, account), sessionID)
- req.Header.Set("Session_ID", isolated)
- req.Header.Set("Conversation_ID", isolated)
- }
- applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), apiKeyID)
- enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
- account.ApplyHeaderOverrides(req.Header)
- return req, nil
- }
- func buildOpenAIAlphaSearchResponsesWebSearchBody(alphaBody []byte, model string) ([]byte, error) {
- if strings.TrimSpace(model) == "" {
- return nil, fmt.Errorf("model is required")
- }
- tool := map[string]any{"type": "web_search"}
- if contextSize := strings.TrimSpace(gjson.GetBytes(alphaBody, "settings.search_context_size").String()); contextSize != "" {
- tool["search_context_size"] = contextSize
- }
- if userLocation := gjson.GetBytes(alphaBody, "settings.user_location"); userLocation.IsObject() {
- var loc map[string]any
- if err := json.Unmarshal([]byte(userLocation.Raw), &loc); err == nil && len(loc) > 0 {
- tool["user_location"] = loc
- }
- }
- payload := map[string]any{
- "model": model,
- "stream": true,
- "store": false,
- "input": []any{
- map[string]any{
- "role": "user",
- "content": []any{
- map[string]any{
- "type": "input_text",
- "text": openAIAlphaSearchResponsesWebSearchPrompt(alphaBody),
- },
- },
- },
- },
- "tools": []any{tool},
- }
- return json.Marshal(payload)
- }
- func openAIAlphaSearchResponsesWebSearchPrompt(alphaBody []byte) string {
- var b strings.Builder
- _, _ = b.WriteString("Execute this Codex standalone web.run request for another model.\n")
- _, _ = b.WriteString("Use the hosted web_search tool when web/current information is needed.\n")
- _, _ = b.WriteString("Return concise source-backed results. Include titles, URLs, dates, and direct answers when available.\n")
- if commands := strings.TrimSpace(gjson.GetBytes(alphaBody, "commands").Raw); commands != "" {
- _, _ = b.WriteString("\nCommands JSON:\n")
- _, _ = b.WriteString(truncateOpenAIAlphaSearchPromptJSON(commands, 12000))
- }
- if settings := strings.TrimSpace(gjson.GetBytes(alphaBody, "settings").Raw); settings != "" {
- _, _ = b.WriteString("\n\nSearch settings JSON:\n")
- _, _ = b.WriteString(truncateOpenAIAlphaSearchPromptJSON(settings, 4000))
- }
- if input := strings.TrimSpace(gjson.GetBytes(alphaBody, "input").Raw); input != "" {
- _, _ = b.WriteString("\n\nRecent conversation/input JSON:\n")
- _, _ = b.WriteString(truncateOpenAIAlphaSearchPromptJSON(input, 8000))
- }
- if b.Len() == 0 {
- return "Execute the requested web search and return concise source-backed results."
- }
- return b.String()
- }
- func truncateOpenAIAlphaSearchPromptJSON(value string, limit int) string {
- value = strings.TrimSpace(value)
- if limit <= 0 || len(value) <= limit {
- return value
- }
- return value[:limit] + "\n...<truncated>"
- }
- func (s *OpenAIGatewayService) buildOpenAIAlphaSearchRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string) (*http.Request, error) {
- targetURL, err := s.openAIAlphaSearchURL(account)
- if err != nil {
- return nil, err
- }
- parsedURL, err := url.Parse(targetURL)
- if err != nil {
- return nil, fmt.Errorf("parse alpha search URL: %w", err)
- }
- if c != nil && c.Request != nil && c.Request.URL != nil {
- query := parsedURL.Query()
- for key, values := range c.Request.URL.Query() {
- for _, value := range values {
- query.Add(key, value)
- }
- }
- parsedURL.RawQuery = query.Encode()
- }
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsedURL.String(), bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
- authHeaders, err := s.buildOpenAIAuthenticationHeaders(ctx, account, token)
- if err != nil {
- return nil, fmt.Errorf("build openai authentication headers: %w", err)
- }
- for key, values := range authHeaders {
- for _, value := range values {
- req.Header.Add(key, value)
- }
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- if account.Type == AccountTypeOAuth {
- req.Host = "chatgpt.com"
- if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, req.Header, account); err != nil {
- return nil, fmt.Errorf("resolve chatgpt account headers: %w", err)
- }
- if turnMetadata := openAIAlphaSearchInboundHeader(c, "X-Codex-Turn-Metadata"); turnMetadata != "" {
- req.Header.Set("X-Codex-Turn-Metadata", turnMetadata)
- }
- applyCodexAccountIdentityHeaders(req.Header, codexAccountIdentitySource(c, account), getAPIKeyIDFromContext(c))
- canonical := resolveCodexOutboundIdentity("")
- if version := openAIAlphaSearchInboundHeader(c, "Version"); version != "" {
- req.Header.Set("Version", version)
- } else {
- req.Header.Set("Version", canonical.version)
- }
- if originator := openAIAlphaSearchInboundHeader(c, "Originator"); originator != "" {
- req.Header.Set("Originator", originator)
- } else {
- req.Header.Set("Originator", canonical.originator)
- }
- if customUA := account.GetOpenAIUserAgent(); customUA != "" {
- req.Header.Set("User-Agent", customUA)
- } else if userAgent := openAIAlphaSearchInboundHeader(c, "User-Agent"); userAgent != "" {
- req.Header.Set("User-Agent", userAgent)
- } else {
- req.Header.Set("User-Agent", canonical.userAgent)
- }
- if s.cfg != nil && s.cfg.Gateway.ForceCodexCLI {
- req.Header.Set("User-Agent", canonical.userAgent)
- }
- enforceCodexIdentityHeadersWithUA(req.Header, s.codexIdentityOverrideUA(account))
- }
- account.ApplyHeaderOverrides(req.Header)
- stripOpenAIAlphaSearchResponsesHeaders(req.Header)
- return req, nil
- }
- // stripOpenAIAlphaSearchResponsesHeaders 让独立搜索请求与官方 Codex
- // SearchClient 的线协议保持一致。alpha/search 不是 /responses 的子请求:官方
- // 客户端仅在 Provider/Auth 基础头之外附加 x-codex-turn-metadata,不发送
- // OpenAI-Beta、会话隔离或 Responses Lite 状态头。originator 与 User-Agent
- // 属于官方默认客户端头,必须保留。
- //
- // alpha/search 使用专用构造器生成官方 SearchClient 的最小线协议形态;
- // 该函数作为最后一道防线,避免账号 header 覆写或后续改动重新带入
- // Responses 专用头,使 PAT 的 alpha/search 被上游按错误认证路径处理。
- func stripOpenAIAlphaSearchResponsesHeaders(headers http.Header) {
- if headers == nil {
- return
- }
- for _, key := range []string{
- "OpenAI-Beta",
- "Session_ID",
- "Conversation_ID",
- "X-Codex-Beta-Features",
- "X-Codex-Turn-State",
- responsesLiteHeaderKey,
- } {
- headers.Del(key)
- }
- }
- func openAIAlphaSearchInboundHeader(c *gin.Context, key string) string {
- if c == nil {
- return ""
- }
- return strings.TrimSpace(c.GetHeader(key))
- }
- var openAIAlphaSearchUnsupportedBodyFields = [...]string{
- // Codex alpha/search 是 SearchRequest 独立协议,不是 /responses 子请求。
- // 新版 Codex/第三方代理可能把 Responses 公共字段误带到搜索请求里;ChatGPT
- // alpha/search 会对这些字段返回 Unknown parameter(例如 prompt_cache_key)。
- "prompt_cache_key",
- "prompt_cache_retention",
- "store",
- }
- func sanitizeOpenAIAlphaSearchBody(body []byte) ([]byte, error) {
- if len(body) == 0 {
- return body, nil
- }
- var obj map[string]json.RawMessage
- if err := json.Unmarshal(body, &obj); err != nil || obj == nil {
- return body, nil
- }
- changed := false
- for _, field := range openAIAlphaSearchUnsupportedBodyFields {
- if _, ok := obj[field]; ok {
- delete(obj, field)
- changed = true
- }
- }
- if !changed {
- return body, nil
- }
- out, err := json.Marshal(obj)
- if err != nil {
- return nil, err
- }
- return out, nil
- }
- func (s *OpenAIGatewayService) ensureOpenAIAlphaSearchAuthMetadata(ctx context.Context, account *Account, token string, proxyURL string) error {
- if s == nil || account == nil || !account.IsOpenAIPersonalAccessToken() {
- return nil
- }
- if strings.TrimSpace(account.GetChatGPTAccountID()) != "" {
- return nil
- }
- var oauthService *OpenAIOAuthService
- if s.openAITokenProvider != nil {
- oauthService = s.openAITokenProvider.openAIOAuthService
- }
- if oauthService == nil {
- return nil
- }
- tokenInfo, err := oauthService.ValidateCodexPersonalAccessToken(ctx, token, proxyURL)
- if err != nil {
- return fmt.Errorf("validate Codex PAT metadata for alpha/search: %w", err)
- }
- credentials := shallowCopyMap(account.Credentials)
- for key, value := range oauthService.BuildAccountCredentials(tokenInfo) {
- credentials[key] = value
- }
- credentials = NormalizeOpenAIPersonalAccessTokenCredentials(account, tokenInfo, credentials)
- account.Credentials = shallowCopyMap(credentials)
- if s.accountRepo != nil {
- if err := persistAccountCredentials(ctx, s.accountRepo, account, credentials); err != nil {
- return fmt.Errorf("persist Codex PAT metadata for alpha/search: %w", err)
- }
- }
- return nil
- }
- // isOpenAIAlphaSearchEndpointUnsupported 识别「API key 上游没有实现
- // /v1/alpha/search 端点」的响应。404/405 不在通用 failover 状态集里(模型
- // 调用中的 404 通常是用户请求问题),但对这个独立工具端点而言,它几乎只
- // 意味着所选上游(官方平台或第三方中转)不提供该端点——应换号重试,而
- // 不是把 404 透传给客户端,否则混合分组里 OAuth 账号明明可以承接搜索,
- // 请求却可能死在先被选中的 API key 账号上。
- func isOpenAIAlphaSearchEndpointUnsupported(account *Account, statusCode int) bool {
- if account == nil || account.Type != AccountTypeAPIKey {
- return false
- }
- return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed
- }
- func shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(statusCode int) bool {
- switch statusCode {
- case http.StatusUnauthorized, http.StatusNotFound, http.StatusMethodNotAllowed:
- // 401:工具端点的 access enforcement 不代表凭据全局失效;
- // 404/405:端点不存在只说明该上游不支持独立搜索,账号本身健康。
- // 两类都只换号,不写账号错误状态。
- return false
- default:
- return true
- }
- }
- func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) {
- output, results := parseOpenAIResponsesSSEForAlphaSearch(body)
- resp := map[string]any{
- "output": output,
- }
- if len(results) > 0 {
- resp["results"] = results
- }
- return json.Marshal(resp)
- }
- func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any) {
- text := strings.ReplaceAll(string(body), "\r\n", "\n")
- var output strings.Builder
- var completedResponse any
- results := make([]any, 0)
- seenURLs := make(map[string]struct{})
- for _, block := range strings.Split(text, "\n\n") {
- data := openAIAlphaSearchSSEData(block)
- if data == "" || data == "[DONE]" {
- continue
- }
- var event map[string]any
- if err := json.Unmarshal([]byte(data), &event); err != nil {
- continue
- }
- if delta, _ := event["delta"].(string); delta != "" && event["type"] == "response.output_text.delta" {
- _, _ = output.WriteString(delta)
- }
- if event["type"] == "response.completed" {
- completedResponse = event["response"]
- }
- collectOpenAIAlphaSearchURLCitations(event, &results, seenURLs)
- }
- out := output.String()
- if strings.TrimSpace(out) == "" && completedResponse != nil {
- out = extractOpenAIResponsesCompletedText(completedResponse)
- collectOpenAIAlphaSearchURLCitations(completedResponse, &results, seenURLs)
- }
- return out, results
- }
- func openAIAlphaSearchSSEData(block string) string {
- var lines []string
- for _, line := range strings.Split(block, "\n") {
- line = strings.TrimRight(line, "\r")
- if !strings.HasPrefix(line, "data:") {
- continue
- }
- lines = append(lines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
- }
- return strings.TrimSpace(strings.Join(lines, "\n"))
- }
- func extractOpenAIResponsesCompletedText(response any) string {
- resp, ok := response.(map[string]any)
- if !ok {
- return ""
- }
- outputItems, _ := resp["output"].([]any)
- var b strings.Builder
- for _, item := range outputItems {
- itemMap, ok := item.(map[string]any)
- if !ok || itemMap["type"] != "message" {
- continue
- }
- contentItems, _ := itemMap["content"].([]any)
- for _, content := range contentItems {
- contentMap, ok := content.(map[string]any)
- if !ok {
- continue
- }
- if contentMap["type"] == "output_text" {
- if text, _ := contentMap["text"].(string); text != "" {
- _, _ = b.WriteString(text)
- }
- }
- }
- }
- return b.String()
- }
- func collectOpenAIAlphaSearchURLCitations(value any, results *[]any, seen map[string]struct{}) {
- switch typed := value.(type) {
- case map[string]any:
- if typed["type"] == "url_citation" {
- if urlValue, _ := typed["url"].(string); strings.TrimSpace(urlValue) != "" {
- urlValue = strings.TrimSpace(urlValue)
- if _, exists := seen[urlValue]; !exists {
- seen[urlValue] = struct{}{}
- result := map[string]any{
- "type": "text_result",
- "ref_id": fmt.Sprintf("turn0search%d", len(*results)),
- "url": urlValue,
- }
- if title, _ := typed["title"].(string); strings.TrimSpace(title) != "" {
- result["title"] = strings.TrimSpace(title)
- }
- *results = append(*results, result)
- }
- }
- }
- for _, child := range typed {
- collectOpenAIAlphaSearchURLCitations(child, results, seen)
- }
- case []any:
- for _, child := range typed {
- collectOpenAIAlphaSearchURLCitations(child, results, seen)
- }
- }
- }
- func (s *OpenAIGatewayService) openAIAlphaSearchURL(account *Account) (string, error) {
- if account == nil {
- return "", fmt.Errorf("account is required")
- }
- switch account.Type {
- case AccountTypeOAuth, AccountTypeSetupToken:
- return chatgptCodexAlphaSearchURL, nil
- case AccountTypeAPIKey:
- baseURL := account.GetOpenAIBaseURL()
- if baseURL == "" {
- return openAIPlatformAlphaSearchURL, nil
- }
- validatedURL, err := s.validateUpstreamBaseURL(baseURL)
- if err != nil {
- return "", err
- }
- return buildOpenAIEndpointURL(validatedURL, "/v1/alpha/search"), nil
- default:
- return "", fmt.Errorf("unsupported OpenAI account type: %s", account.Type)
- }
- }
|