mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 08:19:58 +00:00
fix(openai): 转发 Codex alpha/search 独立搜索端点
This commit is contained in:
@@ -18,6 +18,7 @@ const (
|
|||||||
EndpointMessages = "/v1/messages"
|
EndpointMessages = "/v1/messages"
|
||||||
EndpointChatCompletions = "/v1/chat/completions"
|
EndpointChatCompletions = "/v1/chat/completions"
|
||||||
EndpointEmbeddings = "/v1/embeddings"
|
EndpointEmbeddings = "/v1/embeddings"
|
||||||
|
EndpointAlphaSearch = "/v1/alpha/search"
|
||||||
EndpointResponses = "/v1/responses"
|
EndpointResponses = "/v1/responses"
|
||||||
EndpointResponsesCompact = "/v1/responses/compact"
|
EndpointResponsesCompact = "/v1/responses/compact"
|
||||||
EndpointImagesGenerations = "/v1/images/generations"
|
EndpointImagesGenerations = "/v1/images/generations"
|
||||||
@@ -75,6 +76,8 @@ func NormalizeInboundEndpoint(path string) string {
|
|||||||
switch {
|
switch {
|
||||||
case strings.Contains(path, EndpointEmbeddings):
|
case strings.Contains(path, EndpointEmbeddings):
|
||||||
return EndpointEmbeddings
|
return EndpointEmbeddings
|
||||||
|
case strings.Contains(path, EndpointAlphaSearch) || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/alpha/search") || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/backend-api/codex/alpha/search"):
|
||||||
|
return EndpointAlphaSearch
|
||||||
case strings.Contains(path, EndpointChatCompletions):
|
case strings.Contains(path, EndpointChatCompletions):
|
||||||
return EndpointChatCompletions
|
return EndpointChatCompletions
|
||||||
case strings.Contains(path, EndpointMessages):
|
case strings.Contains(path, EndpointMessages):
|
||||||
@@ -155,8 +158,8 @@ func isBareOrSubpathOf(path, root string) bool {
|
|||||||
// account platform and the normalized inbound endpoint.
|
// account platform and the normalized inbound endpoint.
|
||||||
//
|
//
|
||||||
// Platform-specific rules:
|
// Platform-specific rules:
|
||||||
// - OpenAI always forwards to /v1/responses (with optional subpath
|
// - OpenAI text compatibility routes forward to /v1/responses; native
|
||||||
// such as /v1/responses/compact preserved from the raw URL).
|
// endpoints such as embeddings and alpha search retain their paths.
|
||||||
// - Anthropic → /v1/messages
|
// - Anthropic → /v1/messages
|
||||||
// - Gemini → /v1beta/models
|
// - Gemini → /v1beta/models
|
||||||
// - Antigravity → /v1/messages (Claude) or gemini (Gemini)
|
// - Antigravity → /v1/messages (Claude) or gemini (Gemini)
|
||||||
@@ -167,7 +170,7 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
|
|||||||
|
|
||||||
switch platform {
|
switch platform {
|
||||||
case service.PlatformOpenAI, service.PlatformGrok:
|
case service.PlatformOpenAI, service.PlatformGrok:
|
||||||
if inbound == EndpointEmbeddings || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideos {
|
if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideos {
|
||||||
return inbound
|
return inbound
|
||||||
}
|
}
|
||||||
// OpenAI forwards everything to the Responses API.
|
// OpenAI forwards everything to the Responses API.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
|
|||||||
{"/v1/messages", EndpointMessages},
|
{"/v1/messages", EndpointMessages},
|
||||||
{"/v1/chat/completions", EndpointChatCompletions},
|
{"/v1/chat/completions", EndpointChatCompletions},
|
||||||
{"/v1/embeddings", EndpointEmbeddings},
|
{"/v1/embeddings", EndpointEmbeddings},
|
||||||
|
{"/v1/alpha/search", EndpointAlphaSearch},
|
||||||
{"/v1/responses", EndpointResponses},
|
{"/v1/responses", EndpointResponses},
|
||||||
{"/v1/responses/compact", EndpointResponsesCompact},
|
{"/v1/responses/compact", EndpointResponsesCompact},
|
||||||
{"/v1/responses/compact/detail", EndpointResponsesCompact},
|
{"/v1/responses/compact/detail", EndpointResponsesCompact},
|
||||||
@@ -50,11 +51,13 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
|
|||||||
{"/responses", EndpointResponses},
|
{"/responses", EndpointResponses},
|
||||||
{"/responses/compact", EndpointResponsesCompact},
|
{"/responses/compact", EndpointResponsesCompact},
|
||||||
{"/responses/compact/detail", EndpointResponsesCompact},
|
{"/responses/compact/detail", EndpointResponsesCompact},
|
||||||
|
{"/alpha/search", EndpointAlphaSearch},
|
||||||
|
|
||||||
// Bare Codex direct alias route — root vs. compact.
|
// Bare Codex direct alias route — root vs. compact.
|
||||||
{"/backend-api/codex/responses", EndpointResponses},
|
{"/backend-api/codex/responses", EndpointResponses},
|
||||||
{"/backend-api/codex/responses/compact", EndpointResponsesCompact},
|
{"/backend-api/codex/responses/compact", EndpointResponsesCompact},
|
||||||
{"/backend-api/codex/responses/compact/detail", EndpointResponsesCompact},
|
{"/backend-api/codex/responses/compact/detail", EndpointResponsesCompact},
|
||||||
|
{"/backend-api/codex/alpha/search", EndpointAlphaSearch},
|
||||||
|
|
||||||
// Must NOT generalize to arbitrary paths merely ending in
|
// Must NOT generalize to arbitrary paths merely ending in
|
||||||
// "/responses" (or "/responses/compact") that are unrelated to
|
// "/responses" (or "/responses/compact") that are unrelated to
|
||||||
@@ -119,6 +122,7 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
|
|||||||
{"openai from messages", EndpointMessages, "/v1/messages", service.PlatformOpenAI, EndpointResponses},
|
{"openai from messages", EndpointMessages, "/v1/messages", service.PlatformOpenAI, EndpointResponses},
|
||||||
{"openai from completions", EndpointChatCompletions, "/v1/chat/completions", service.PlatformOpenAI, EndpointResponses},
|
{"openai from completions", EndpointChatCompletions, "/v1/chat/completions", service.PlatformOpenAI, EndpointResponses},
|
||||||
{"openai embeddings", EndpointEmbeddings, "/v1/embeddings", service.PlatformOpenAI, EndpointEmbeddings},
|
{"openai embeddings", EndpointEmbeddings, "/v1/embeddings", service.PlatformOpenAI, EndpointEmbeddings},
|
||||||
|
{"openai alpha search", EndpointAlphaSearch, "/backend-api/codex/alpha/search", service.PlatformOpenAI, EndpointAlphaSearch},
|
||||||
{"openai image generations", EndpointImagesGenerations, "/v1/images/generations", service.PlatformOpenAI, EndpointImagesGenerations},
|
{"openai image generations", EndpointImagesGenerations, "/v1/images/generations", service.PlatformOpenAI, EndpointImagesGenerations},
|
||||||
{"openai image edits", EndpointImagesEdits, "/openai/v1/images/edits", service.PlatformOpenAI, EndpointImagesEdits},
|
{"openai image edits", EndpointImagesEdits, "/openai/v1/images/edits", service.PlatformOpenAI, EndpointImagesEdits},
|
||||||
{"grok video generations", EndpointVideosGenerations, "/v1/videos/generations", service.PlatformGrok, EndpointVideosGenerations},
|
{"grok video generations", EndpointVideosGenerations, "/v1/videos/generations", service.PlatformGrok, EndpointVideosGenerations},
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
||||||
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AlphaSearch proxies the standalone search endpoint used by Codex Responses Lite.
|
||||||
|
func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||||
|
streamStarted := false
|
||||||
|
defer h.recoverResponsesPanic(c, &streamStarted)
|
||||||
|
setOpenAIClientTransportHTTP(c)
|
||||||
|
requestStart := time.Now()
|
||||||
|
|
||||||
|
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||||
|
if !ok || apiKey.Group == nil {
|
||||||
|
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if apiKey.Group.Platform != service.PlatformOpenAI {
|
||||||
|
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search is only available for OpenAI groups")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reqLog := requestLogger(
|
||||||
|
c,
|
||||||
|
"handler.openai_gateway.alpha_search",
|
||||||
|
zap.Int64("user_id", subject.UserID),
|
||||||
|
zap.Int64("api_key_id", apiKey.ID),
|
||||||
|
zap.Any("group_id", apiKey.GroupID),
|
||||||
|
)
|
||||||
|
if !h.ensureResponsesDependencies(c, reqLog) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
|
||||||
|
if err != nil {
|
||||||
|
if maxErr, ok := extractMaxBytesError(err); ok {
|
||||||
|
h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body) == 0 {
|
||||||
|
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !gjson.ValidBytes(body) {
|
||||||
|
logRequestBodyParseFailure(reqLog, body, nil)
|
||||||
|
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelResult := gjson.GetBytes(body, "model")
|
||||||
|
if !modelResult.Exists() || modelResult.Type != gjson.String || strings.TrimSpace(modelResult.String()) == "" {
|
||||||
|
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestedModel := strings.TrimSpace(modelResult.String())
|
||||||
|
reqLog = reqLog.With(zap.String("model", requestedModel))
|
||||||
|
setOpsRequestContext(c, requestedModel, false)
|
||||||
|
setOpsEndpointContext(c, "", int16(service.RequestTypeSync))
|
||||||
|
|
||||||
|
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, requestedModel)
|
||||||
|
forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody)
|
||||||
|
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||||
|
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
|
||||||
|
|
||||||
|
userRelease, acquired := h.acquireResponsesUserSlot(c, subject.UserID, subject.Concurrency, false, &streamStarted, reqLog)
|
||||||
|
if !acquired {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if userRelease != nil {
|
||||||
|
defer userRelease()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
|
||||||
|
status, code, message, retryAfter := billingErrorDetails(err)
|
||||||
|
if retryAfter > 0 {
|
||||||
|
c.Header("Retry-After", strconv.Itoa(retryAfter))
|
||||||
|
}
|
||||||
|
h.errorResponse(c, status, code, message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchID := strings.TrimSpace(gjson.GetBytes(body, "id").String())
|
||||||
|
sessionHash := h.gatewayService.GenerateSessionHashWithFallback(c, nil, searchID)
|
||||||
|
failedAccountIDs := make(map[int64]struct{})
|
||||||
|
var lastFailoverErr *service.UpstreamFailoverError
|
||||||
|
switchCount := 0
|
||||||
|
routingStart := time.Now()
|
||||||
|
|
||||||
|
for {
|
||||||
|
selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||||
|
c.Request.Context(),
|
||||||
|
apiKey.GroupID,
|
||||||
|
"",
|
||||||
|
sessionHash,
|
||||||
|
requestedModel,
|
||||||
|
failedAccountIDs,
|
||||||
|
service.OpenAIUpstreamTransportHTTPSSE,
|
||||||
|
service.OpenAIEndpointCapabilityChatCompletions,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
service.PlatformOpenAI,
|
||||||
|
)
|
||||||
|
if err != nil || selection == nil || selection.Account == nil {
|
||||||
|
if len(failedAccountIDs) == 0 {
|
||||||
|
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestedModel, requestedModel, service.PlatformOpenAI)
|
||||||
|
if !cls.ModelNotFound {
|
||||||
|
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||||
|
}
|
||||||
|
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if lastFailoverErr != nil {
|
||||||
|
h.handleFailoverExhausted(c, lastFailoverErr, false)
|
||||||
|
} else {
|
||||||
|
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account := selection.Account
|
||||||
|
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||||
|
accountRelease, acquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
|
||||||
|
if !acquired {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
|
||||||
|
writerSizeBeforeForward := c.Writer.Size()
|
||||||
|
forwardStart := time.Now()
|
||||||
|
err = func() error {
|
||||||
|
if accountRelease != nil {
|
||||||
|
defer accountRelease()
|
||||||
|
}
|
||||||
|
return h.gatewayService.ForwardAlphaSearch(c.Request.Context(), c, account, forwardBody)
|
||||||
|
}()
|
||||||
|
service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, time.Since(forwardStart).Milliseconds())
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var failoverErr *service.UpstreamFailoverError
|
||||||
|
if !errors.As(err, &failoverErr) {
|
||||||
|
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||||
|
if c.Writer.Size() == writerSizeBeforeForward {
|
||||||
|
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
|
||||||
|
}
|
||||||
|
reqLog.Warn("openai_alpha_search.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil)
|
||||||
|
if c.Writer.Size() != writerSizeBeforeForward {
|
||||||
|
h.handleFailoverExhausted(c, failoverErr, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.gatewayService.RecordOpenAIAccountSwitch()
|
||||||
|
failedAccountIDs[account.ID] = struct{}{}
|
||||||
|
lastFailoverErr = failoverErr
|
||||||
|
if switchCount >= h.maxAccountSwitches {
|
||||||
|
h.handleFailoverExhausted(c, failoverErr, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switchCount++
|
||||||
|
if h.gatewayService.ShouldStopOpenAIOAuth429Failover(account, failoverErr.StatusCode, switchCount) {
|
||||||
|
h.handleFailoverExhausted(c, failoverErr, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reqLog.Warn("openai_alpha_search.upstream_failover_switching",
|
||||||
|
zap.Int64("account_id", account.ID),
|
||||||
|
zap.Int("upstream_status", failoverErr.StatusCode),
|
||||||
|
zap.Int("switch_count", switchCount),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -147,6 +147,7 @@ func RegisterGatewayRoutes(
|
|||||||
}
|
}
|
||||||
h.Gateway.Responses(c)
|
h.Gateway.Responses(c)
|
||||||
})
|
})
|
||||||
|
gateway.POST("/alpha/search", h.OpenAIGateway.AlphaSearch)
|
||||||
gateway.GET("/responses", func(c *gin.Context) {
|
gateway.GET("/responses", func(c *gin.Context) {
|
||||||
h.OpenAIGateway.ResponsesWebSocket(c)
|
h.OpenAIGateway.ResponsesWebSocket(c)
|
||||||
})
|
})
|
||||||
@@ -212,6 +213,7 @@ func RegisterGatewayRoutes(
|
|||||||
}
|
}
|
||||||
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
|
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
|
||||||
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
|
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, responsesHandler)
|
||||||
|
r.POST("/alpha/search", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.AlphaSearch)
|
||||||
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
|
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) {
|
||||||
h.OpenAIGateway.ResponsesWebSocket(c)
|
h.OpenAIGateway.ResponsesWebSocket(c)
|
||||||
})
|
})
|
||||||
@@ -220,6 +222,7 @@ func RegisterGatewayRoutes(
|
|||||||
{
|
{
|
||||||
codexDirect.POST("/responses", responsesHandler)
|
codexDirect.POST("/responses", responsesHandler)
|
||||||
codexDirect.POST("/responses/*subpath", responsesHandler)
|
codexDirect.POST("/responses/*subpath", responsesHandler)
|
||||||
|
codexDirect.POST("/alpha/search", h.OpenAIGateway.AlphaSearch)
|
||||||
codexDirect.GET("/responses", func(c *gin.Context) {
|
codexDirect.GET("/responses", func(c *gin.Context) {
|
||||||
h.OpenAIGateway.ResponsesWebSocket(c)
|
h.OpenAIGateway.ResponsesWebSocket(c)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -65,6 +65,36 @@ func TestGatewayRoutesOpenAIResponsesCompactPathIsRegistered(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGatewayRoutesOpenAIAlphaSearchPathsAreRegistered(t *testing.T) {
|
||||||
|
router := newGatewayRoutesTestRouter()
|
||||||
|
registered := make(map[string]bool)
|
||||||
|
for _, route := range router.Routes() {
|
||||||
|
if route.Method == http.MethodPost {
|
||||||
|
registered[route.Path] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
"/v1/alpha/search",
|
||||||
|
"/alpha/search",
|
||||||
|
"/backend-api/codex/alpha/search",
|
||||||
|
} {
|
||||||
|
require.True(t, registered[path], "POST %s should be registered", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayRoutesAlphaSearchRejectsNonOpenAIGroup(t *testing.T) {
|
||||||
|
router := newGatewayRoutesTestRouter(service.PlatformGrok)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/v1/alpha/search", strings.NewReader(`{"model":"gpt-5.6-sol"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
require.Contains(t, w.Body.String(), "only available for OpenAI groups")
|
||||||
|
}
|
||||||
|
|
||||||
func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) {
|
func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) {
|
||||||
router := newGatewayRoutesTestRouter()
|
router := newGatewayRoutesTestRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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.
|
||||||
|
func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Context, account *Account, body []byte) error {
|
||||||
|
if s == nil || c == nil || account == nil {
|
||||||
|
return 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 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 err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := s.buildOpenAIAlphaSearchRequest(ctx, c, account, body, token)
|
||||||
|
if err != nil {
|
||||||
|
return 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 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 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 &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)
|
||||||
|
return 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestForwardAlphaSearchOAuthPreservesWire(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
body := []byte(`{
|
||||||
|
"id":"search-session",
|
||||||
|
"model":"gpt-5.6-sol",
|
||||||
|
"reasoning":{"effort":"max","context":"all_turns"},
|
||||||
|
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"latest news"}]}],
|
||||||
|
"commands":{"search_query":[{"q":"OpenAI news","recency":1}]},
|
||||||
|
"settings":{"allowed_callers":["direct"],"external_web_access":true},
|
||||||
|
"max_output_tokens":2000,
|
||||||
|
"future_field":{"keep":true}
|
||||||
|
}`)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search?feature=standalone", bytes.NewReader(body))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
c.Request.Header.Set("User-Agent", codexCLIUserAgent)
|
||||||
|
c.Request.Header.Set("Originator", "codex_cli_rs")
|
||||||
|
c.Request.Header.Set("Version", "0.144.1")
|
||||||
|
|
||||||
|
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"encrypted_output":"ciphertext","output":"search result"}`)),
|
||||||
|
}}
|
||||||
|
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||||
|
account := &Account{
|
||||||
|
ID: 42,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeOAuth,
|
||||||
|
Concurrency: 1,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"access_token": "oauth-token",
|
||||||
|
"chatgpt_account_id": "chatgpt-account",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusOK, recorder.Code)
|
||||||
|
require.JSONEq(t, `{"encrypted_output":"ciphertext","output":"search result"}`, recorder.Body.String())
|
||||||
|
require.Equal(t, chatgptCodexAlphaSearchURL+"?feature=standalone", upstream.lastReq.URL.String())
|
||||||
|
require.Equal(t, "chatgpt.com", upstream.lastReq.Host)
|
||||||
|
require.Equal(t, "Bearer oauth-token", upstream.lastReq.Header.Get("Authorization"))
|
||||||
|
require.Equal(t, "chatgpt-account", upstream.lastReq.Header.Get("chatgpt-account-id"))
|
||||||
|
require.Equal(t, "application/json", upstream.lastReq.Header.Get("Accept"))
|
||||||
|
require.Equal(t, "0.144.1", upstream.lastReq.Header.Get("Version"))
|
||||||
|
require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta"))
|
||||||
|
require.JSONEq(t, string(body), string(upstream.lastBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardAlphaSearchAPIKeyMapsModelAndPassesThroughError(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/alpha/search", bytes.NewReader(body))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
upstreamBody := `{"error":{"type":"invalid_request_error","message":"bad search"}}`
|
||||||
|
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||||
|
StatusCode: http.StatusBadRequest,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||||
|
}}
|
||||||
|
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||||
|
account := &Account{
|
||||||
|
ID: 7,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"api_key": "sk-test",
|
||||||
|
"base_url": "https://compat.example/v4",
|
||||||
|
"model_mapping": map[string]any{
|
||||||
|
"gpt-5.6-sol": "upstream-5.6",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||||
|
require.JSONEq(t, upstreamBody, recorder.Body.String())
|
||||||
|
require.Equal(t, "https://compat.example/v4/alpha/search", upstream.lastReq.URL.String())
|
||||||
|
require.Equal(t, "Bearer sk-test", upstream.lastReq.Header.Get("Authorization"))
|
||||||
|
require.Equal(t, "upstream-5.6", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||||
|
require.True(t, gjson.GetBytes(upstream.lastBody, "commands.search_query").IsArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardAlphaSearchReturnsFailoverBeforeWriting(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{}}`)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
|
||||||
|
|
||||||
|
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||||
|
StatusCode: http.StatusTooManyRequests,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"rate limited"}}`)),
|
||||||
|
}}
|
||||||
|
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||||
|
account := &Account{
|
||||||
|
ID: 8,
|
||||||
|
Platform: PlatformOpenAI,
|
||||||
|
Type: AccountTypeAPIKey,
|
||||||
|
Credentials: map[string]any{
|
||||||
|
"api_key": "sk-test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := service.ForwardAlphaSearch(context.Background(), c, account, body)
|
||||||
|
|
||||||
|
var failoverErr *UpstreamFailoverError
|
||||||
|
require.ErrorAs(t, err, &failoverErr)
|
||||||
|
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
|
||||||
|
require.Equal(t, openAIPlatformAlphaSearchURL, upstream.lastReq.URL.String())
|
||||||
|
require.False(t, c.Writer.Written())
|
||||||
|
require.Empty(t, recorder.Body.String())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user