Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
671 lines
24 KiB
Go
671 lines
24 KiB
Go
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")
|
||
}
|
||
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
|
||
}
|
||
|
||
// 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.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||
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(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, upstreamModel)
|
||
}
|
||
return nil, &UpstreamFailoverError{
|
||
StatusCode: resp.StatusCode,
|
||
ResponseBody: respBody,
|
||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||
}
|
||
}
|
||
}
|
||
|
||
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")),
|
||
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.httpUpstream.Do(req, proxyURL, account.ID, account.Concurrency)
|
||
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(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, upstreamModel)
|
||
}
|
||
return nil, &UpstreamFailoverError{
|
||
StatusCode: resp.StatusCode,
|
||
ResponseBody: respBody,
|
||
RetryableOnSameAccount: !shouldDisable && account.IsPoolMode() && account.IsPoolModeRetryableStatus(resp.StatusCode),
|
||
}
|
||
}
|
||
}
|
||
|
||
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")),
|
||
Model: requestedModel,
|
||
UpstreamModel: upstreamModel,
|
||
UpstreamEndpoint: "/v1/responses",
|
||
ResponseHeaders: resp.Header.Clone(),
|
||
Duration: time.Since(upstreamStart),
|
||
WebSearchCalls: 1,
|
||
}, nil
|
||
}
|
||
|
||
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 := isolateOpenAISessionID(apiKeyID, sessionID)
|
||
req.Header.Set("Session_ID", isolated)
|
||
req.Header.Set("Conversation_ID", isolated)
|
||
}
|
||
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)
|
||
}
|
||
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",
|
||
}
|
||
|
||
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:
|
||
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)
|
||
}
|
||
}
|