Files
sub2api/backend/internal/service/openai_alpha_search.go
T
shaw 64a2a31729 fix(billing): 修复复审发现的三处按次计费问题
- HIGH: GetByKeyForAuth 的 WithGroup 显式 Select 补 web_search_price_per_call 列,
  否则 auth 快照里分组覆盖价恒为 nil,覆盖价/免费配置在实际计费中静默失效
- MEDIUM: 按次搜索倍率改用不含高峰因子的基础倍率(与 image/video 按次不变式
  及分组表单价格预览承诺一致),calculateOpenAIRecordUsageCost 新增独立倍率参数
- LOW: 计费门槛从 <400 收紧为严格 2xx,1xx/3xx 透传不计费
2026-07-13 10:14:53 +08:00

166 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"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)
}
token, _, err := s.GetAccessToken(ctx, account)
if err != nil {
return nil, err
}
req, err := s.buildOpenAIAlphaSearchRequest(ctx, c, account, body, token)
if err != nil {
return nil, err
}
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
proxyURL = account.Proxy.URL()
}
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) {
resp.Body = io.NopCloser(bytes.NewReader(respBody))
s.handleFailoverSideEffects(ctx, resp, account, respBody, upstreamModel)
return nil, &UpstreamFailoverError{
StatusCode: resp.StatusCode,
ResponseBody: respBody,
RetryableOnSameAccount: 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) buildOpenAIAlphaSearchRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string) (*http.Request, error) {
clientBeta := ""
if c != nil {
clientBeta = c.GetHeader("OpenAI-Beta")
}
req, err := s.buildUpstreamRequestOpenAIPassthrough(ctx, c, account, body, token)
if err != nil {
return nil, err
}
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.URL = parsedURL
req.Header.Set("Accept", "application/json")
if clientBeta == "" {
req.Header.Del("OpenAI-Beta")
}
if version := strings.TrimSpace(c.GetHeader("Version")); version != "" {
req.Header.Set("Version", version)
} else if account.Type == AccountTypeOAuth {
req.Header.Set("Version", codexCLIVersion)
}
return req, nil
}
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)
}
}