mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
新增 domain/platforms.go 作为具体平台的单一数据来源:每个平台一条登记, 写明展示名、所走网关(anthropic / openai / gemini / antigravity)、是否国产 单一厂商、定价目录 provider 名,顺序即展示顺序。 以下列表与判断改为查平台清单: - "全部具体平台":用户平台额度白名单、错误透传规则平台列表、组合路由目标 校验、上游计费探测资格、调度快照遍历、组合分组的定价 / 模型映射回退、 组合分组默认模型列表; - "OpenAI 网关族":IsOpenAICompatible、NormalizeOpenAICompatiblePlatform、 Responses 兼容路由判断、count_tokens 分发、组合分组的 OpenAI 文本目标; - 国产厂商判断、渠道定价同步的 provider 映射; - 多协议 API Key 供应商相关的请求头覆盖资格、凭证测试、OpenAI 基址回退改用 IsMultiProtocolAPIKeyProvider; - 分组平台与组合路由目标的请求校验改为自定义校验标签 group_platform / concrete_platform,拒绝规则不变(大小写、首尾空格、别名一律拒绝)。 组合分组的逐平台回退是先命中者生效,CompositePrecedencePlatformIDs 保持 原有顺序 anthropic、gemini、openai,其余按登记顺序追加。依赖具体实现的 能力(OAuth、渠道监控、调度阈值、余额 / 用量窗口、Codex 模型目录等)仍在 各自代码中显式列出。 等价性测试把改动前的每个列表与 switch 原样保留为基准,逐一比较派生结果 (含顺序),谓词覆盖全部平台、composite 与非法值 × 全部账号类型。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
682 lines
30 KiB
Go
682 lines
30 KiB
Go
package routes
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/Wei-Shaw/sub2api/internal/domain"
|
|
"github.com/Wei-Shaw/sub2api/internal/handler"
|
|
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel"
|
|
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/tidwall/gjson"
|
|
"github.com/tidwall/sjson"
|
|
)
|
|
|
|
// RegisterGatewayRoutes 注册 API 网关路由(Claude/OpenAI/Gemini 兼容)
|
|
func RegisterGatewayRoutes(
|
|
r *gin.Engine,
|
|
h *handler.Handlers,
|
|
apiKeyAuth middleware.APIKeyAuthMiddleware,
|
|
apiKeyService *service.APIKeyService,
|
|
subscriptionService *service.SubscriptionService,
|
|
opsService *service.OpsService,
|
|
settingService *service.SettingService,
|
|
compositeResolver *service.CompositeRouteResolver,
|
|
cfg *config.Config,
|
|
) {
|
|
bodyLimit := middleware.RequestBodyLimit(cfg.Gateway.MaxBodySize)
|
|
textBodyLimit := middleware.RequestBodyLimit(cfg.Gateway.TextMaxBodySize)
|
|
clientRequestID := middleware.ClientRequestID()
|
|
opsErrorLogger := handler.OpsErrorLoggerMiddleware(opsService)
|
|
endpointNorm := handler.InboundEndpointMiddleware()
|
|
compositeTarget := compositeTargetPlatformMiddleware(compositeResolver)
|
|
compositeGeminiTarget := compositeGeminiTargetPlatformMiddleware(compositeResolver)
|
|
|
|
// 未分组 Key 拦截中间件(按协议格式区分错误响应)
|
|
requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter)
|
|
requireGroupGoogle := middleware.RequireGroupAssignment(settingService, middleware.GoogleErrorWriter)
|
|
|
|
// 分组级模型白名单准入:在 apiKeyAuth 之后、compositeTarget 之前,
|
|
// 保证校验发生在合成路由改写与调度之前,且只看客户端书写的模型名。
|
|
groupModelAllowlist := middleware.GroupModelAllowlist()
|
|
|
|
isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool {
|
|
// openai、grok 与多协议 API Key 供应商经 OpenAI 网关转发(平台清单)。
|
|
return domain.UsesOpenAIGateway(getGroupPlatform(c))
|
|
}
|
|
countTokensHandler := func(c *gin.Context) {
|
|
switch platform := getGroupPlatform(c); {
|
|
case platform == service.PlatformGrok:
|
|
h.OpenAIGateway.GrokCountTokens(c)
|
|
case domain.UsesOpenAIGateway(platform):
|
|
h.OpenAIGateway.CountTokens(c)
|
|
default:
|
|
h.Gateway.CountTokens(c)
|
|
}
|
|
}
|
|
codexModelsHandler := func(c *gin.Context) {
|
|
dispatchCodexModelsGateway(c, h.OpenAIGateway.CodexModels, h.Gateway.CodexModels)
|
|
}
|
|
modelsHandler := func(c *gin.Context) {
|
|
if c.Query("client_version") != "" {
|
|
codexModelsHandler(c)
|
|
return
|
|
}
|
|
h.Gateway.Models(c)
|
|
}
|
|
isOpenAIOnlyEndpointGatewayPlatform := func(c *gin.Context) bool {
|
|
return getGroupPlatform(c) == service.PlatformOpenAI
|
|
}
|
|
imagesHandler := func(c *gin.Context) {
|
|
switch getGroupPlatform(c) {
|
|
case service.PlatformOpenAI:
|
|
h.OpenAIGateway.Images(c)
|
|
case service.PlatformGrok:
|
|
h.OpenAIGateway.GrokImages(c)
|
|
default:
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": gin.H{
|
|
"type": "not_found_error",
|
|
"message": "Images API is not supported for this platform",
|
|
},
|
|
})
|
|
}
|
|
}
|
|
videoGenerationHandler := func(c *gin.Context) {
|
|
// Video status/content lookups below already allow Composite groups; keep
|
|
// task creation aligned so composite keys that route to Grok accounts can
|
|
// submit video generation jobs.
|
|
if platform := getGroupPlatform(c); platform == service.PlatformGrok || platform == service.PlatformComposite {
|
|
h.OpenAIGateway.GrokVideoGeneration(c)
|
|
return
|
|
}
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": gin.H{
|
|
"type": "not_found_error",
|
|
"message": "Videos API is not supported for this platform",
|
|
},
|
|
})
|
|
}
|
|
videoStatusHandler := func(c *gin.Context) {
|
|
// Video status requests do not carry a model, so composite groups cannot
|
|
// be resolved by compositeTargetPlatformMiddleware. Route them through
|
|
// the Grok handler and let scheduler/account selection enforce capacity.
|
|
if getGroupPlatform(c) == service.PlatformGrok || getGroupPlatform(c) == service.PlatformComposite {
|
|
h.OpenAIGateway.GrokVideoStatus(c)
|
|
return
|
|
}
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": gin.H{
|
|
"type": "not_found_error",
|
|
"message": "Videos API is not supported for this platform",
|
|
},
|
|
})
|
|
}
|
|
videoContentHandler := func(c *gin.Context) {
|
|
// Video content requests do not carry a model, so composite groups cannot
|
|
// be resolved by compositeTargetPlatformMiddleware. Route them through
|
|
// the Grok handler just like video status lookups.
|
|
if getGroupPlatform(c) == service.PlatformGrok || getGroupPlatform(c) == service.PlatformComposite {
|
|
h.OpenAIGateway.GrokVideoContent(c)
|
|
return
|
|
}
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": gin.H{
|
|
"type": "not_found_error",
|
|
"message": "Videos API is not supported for this platform",
|
|
},
|
|
})
|
|
}
|
|
videoEditHandler := func(c *gin.Context) {
|
|
if getGroupPlatform(c) == service.PlatformGrok {
|
|
h.OpenAIGateway.GrokVideoEdit(c)
|
|
return
|
|
}
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Videos API is not supported for this platform"}})
|
|
}
|
|
videoExtensionHandler := func(c *gin.Context) {
|
|
if getGroupPlatform(c) == service.PlatformGrok {
|
|
h.OpenAIGateway.GrokVideoExtension(c)
|
|
return
|
|
}
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Videos API is not supported for this platform"}})
|
|
}
|
|
// /responses/*subpath 的子路径会被转发到上游同名端点之后,因此在入口就拒掉
|
|
// 不可转发的子路径,不让它进入调度与转发流程。可转发的判定见
|
|
// service.IsForwardableOpenAIResponsesRequestPath 及 upstream_path_guard.go。
|
|
guardResponsesSubpath := func(next gin.HandlerFunc) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if !service.IsForwardableOpenAIResponsesRequestPath(c) {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
|
"error": gin.H{
|
|
"type": "not_found_error",
|
|
"message": "Unsupported responses subpath",
|
|
},
|
|
})
|
|
return
|
|
}
|
|
if service.IsOpenAIResponsesInputTokensRequestPath(c) && isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
|
h.OpenAIGateway.ResponsesInputTokens(c)
|
|
return
|
|
}
|
|
next(c)
|
|
}
|
|
}
|
|
|
|
// API网关(Claude API兼容)
|
|
gateway := r.Group("/v1")
|
|
gateway.Use(bodyLimit)
|
|
gateway.Use(clientRequestID)
|
|
gateway.Use(opsErrorLogger)
|
|
gateway.Use(endpointNorm)
|
|
gateway.Use(gin.HandlerFunc(apiKeyAuth))
|
|
gateway.GET("/sub2api/billing", h.Gateway.KeyBillingInfo)
|
|
gateway.Use(groupModelAllowlist)
|
|
gateway.Use(compositeTarget)
|
|
gateway.Use(requireGroupAnthropic)
|
|
{
|
|
// /v1/messages: auto-route based on group platform
|
|
gateway.POST("/messages", func(c *gin.Context) {
|
|
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
|
h.OpenAIGateway.Messages(c)
|
|
return
|
|
}
|
|
h.Gateway.Messages(c)
|
|
})
|
|
// System One carries only JSON text, so it uses the text body limit.
|
|
gateway.POST("/systemone", textBodyLimit, h.Gateway.SystemOne)
|
|
// /v1/messages/count_tokens: OpenAI bridges upstream, Grok estimates
|
|
// locally, and Anthropic-compatible platforms retain their existing path.
|
|
gateway.POST("/messages/count_tokens", countTokensHandler)
|
|
// Codex CLI / Codex app refresh their model picker from the provider's
|
|
// /models endpoint with a client_version query and expect the ChatGPT
|
|
// Codex manifest format; other clients keep the OpenAI-style list.
|
|
gateway.GET("/models", modelsHandler)
|
|
// Single-model discovery never selects the Codex client_version manifest.
|
|
gateway.GET("/models/:model", h.Gateway.Models)
|
|
gateway.GET("/usage", h.Gateway.Usage)
|
|
gateway.POST("/live", h.OpenAIGateway.Live)
|
|
gateway.GET("/live/:call_id", h.OpenAIGateway.LiveSideband)
|
|
// OpenAI Responses API: auto-route based on group platform
|
|
gateway.POST("/responses", func(c *gin.Context) {
|
|
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
|
h.OpenAIGateway.Responses(c)
|
|
return
|
|
}
|
|
h.Gateway.Responses(c)
|
|
})
|
|
gateway.POST("/responses/*subpath", guardResponsesSubpath(func(c *gin.Context) {
|
|
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
|
h.OpenAIGateway.Responses(c)
|
|
return
|
|
}
|
|
h.Gateway.Responses(c)
|
|
}))
|
|
gateway.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
|
|
gateway.GET("/responses", func(c *gin.Context) {
|
|
h.OpenAIGateway.ResponsesWebSocket(c)
|
|
})
|
|
// OpenAI Chat Completions API: auto-route based on group platform
|
|
gateway.POST("/chat/completions", func(c *gin.Context) {
|
|
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
|
h.OpenAIGateway.ChatCompletions(c)
|
|
return
|
|
}
|
|
h.Gateway.ChatCompletions(c)
|
|
})
|
|
gateway.POST("/embeddings", textBodyLimit, func(c *gin.Context) {
|
|
if !isOpenAIOnlyEndpointGatewayPlatform(c) {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": gin.H{
|
|
"type": "not_found_error",
|
|
"message": "Embeddings API is not supported for this platform",
|
|
},
|
|
})
|
|
return
|
|
}
|
|
h.OpenAIGateway.Embeddings(c)
|
|
})
|
|
gateway.POST("/images/generations", imagesHandler)
|
|
gateway.POST("/images/edits", imagesHandler)
|
|
gateway.POST("/images/generations/async", h.AsyncImage.Submit)
|
|
gateway.POST("/images/edits/async", h.AsyncImage.Submit)
|
|
gateway.GET("/images/tasks/:task_id", h.AsyncImage.Get)
|
|
gateway.POST("/images/batches", h.BatchImage.Submit)
|
|
gateway.GET("/images/batches", h.BatchImage.List)
|
|
gateway.GET("/images/batches/models", h.BatchImage.Models)
|
|
gateway.GET("/images/batches/:id", h.BatchImage.Get)
|
|
gateway.GET("/images/batches/:id/items", h.BatchImage.Items)
|
|
gateway.GET("/images/batches/:id/items/:custom_id/content", h.BatchImage.ItemContent)
|
|
gateway.GET("/images/batches/:id/download", h.BatchImage.Download)
|
|
gateway.POST("/images/batches/:id/cancel", h.BatchImage.Cancel)
|
|
gateway.DELETE("/images/batches/:id", h.BatchImage.DeleteRecord)
|
|
gateway.DELETE("/images/batches/:id/outputs", h.BatchImage.DeleteOutputs)
|
|
// OpenAI-compatible clients may create through /videos; xAI receives the
|
|
// canonical /videos/generations route inside the Grok media forwarder.
|
|
gateway.POST("/videos", videoGenerationHandler)
|
|
gateway.POST("/videos/generations", videoGenerationHandler)
|
|
gateway.POST("/videos/edits", videoEditHandler)
|
|
gateway.POST("/videos/extensions", videoExtensionHandler)
|
|
gateway.GET("/videos/generations/:request_id/content", videoContentHandler)
|
|
gateway.GET("/videos/edits/:request_id/content", videoContentHandler)
|
|
gateway.GET("/videos/extensions/:request_id/content", videoContentHandler)
|
|
gateway.GET("/videos/generations/:request_id", videoStatusHandler)
|
|
gateway.GET("/videos/edits/:request_id", videoStatusHandler)
|
|
gateway.GET("/videos/extensions/:request_id", videoStatusHandler)
|
|
gateway.GET("/videos/:request_id", videoStatusHandler)
|
|
gateway.GET("/videos/:request_id/content", videoContentHandler)
|
|
|
|
// xAI Voice APIs (Grok platform only): HTTP TTS/STT + Realtime WS.
|
|
// Not part of the creation-center product surface — gateway relay only.
|
|
voiceHandler := func(endpoint string) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if getGroupPlatform(c) != service.PlatformGrok {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}})
|
|
return
|
|
}
|
|
h.OpenAIGateway.GrokVoice(c, endpoint)
|
|
}
|
|
}
|
|
gateway.POST("/tts", voiceHandler("tts"))
|
|
gateway.POST("/stt", voiceHandler("stt"))
|
|
gateway.POST("/custom-voices", voiceHandler("custom-voices"))
|
|
customVoicePathHandler := func(c *gin.Context) {
|
|
if getGroupPlatform(c) != service.PlatformGrok {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}})
|
|
return
|
|
}
|
|
h.OpenAIGateway.GrokVoice(c, grokCustomVoiceEndpoint(c))
|
|
}
|
|
gateway.GET("/custom-voices", voiceHandler("custom-voices"))
|
|
gateway.GET("/custom-voices/:voice_id/audio", customVoicePathHandler)
|
|
gateway.GET("/custom-voices/:voice_id", customVoicePathHandler)
|
|
gateway.PATCH("/custom-voices/:voice_id", customVoicePathHandler)
|
|
gateway.DELETE("/custom-voices/:voice_id", customVoicePathHandler)
|
|
gateway.GET("/realtime", func(c *gin.Context) {
|
|
if getGroupPlatform(c) != service.PlatformGrok {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Realtime API is not supported for this platform"}})
|
|
return
|
|
}
|
|
h.OpenAIGateway.GrokRealtime(c)
|
|
})
|
|
gateway.POST("/web_search", func(c *gin.Context) {
|
|
if getGroupPlatform(c) != service.PlatformGrok {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Web Search API is not supported for this platform"}})
|
|
return
|
|
}
|
|
h.Gateway.WebSearch(c)
|
|
})
|
|
gateway.POST("/x_search", func(c *gin.Context) {
|
|
if getGroupPlatform(c) != service.PlatformGrok {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "X Search API is not supported for this platform"}})
|
|
return
|
|
}
|
|
h.Gateway.XSearch(c)
|
|
})
|
|
}
|
|
|
|
// Gemini 原生 API 兼容层(Gemini SDK/CLI 直连)
|
|
gemini := r.Group("/v1beta")
|
|
gemini.Use(bodyLimit)
|
|
gemini.Use(clientRequestID)
|
|
gemini.Use(opsErrorLogger)
|
|
gemini.Use(endpointNorm)
|
|
gemini.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg))
|
|
gemini.Use(groupModelAllowlist)
|
|
gemini.Use(compositeGeminiTarget)
|
|
gemini.Use(requireGroupGoogle)
|
|
{
|
|
gemini.GET("/models", h.Gateway.GeminiV1BetaListModels)
|
|
gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
|
|
// Gin treats ":" as a param marker, but Gemini uses "{model}:{action}" in the same segment.
|
|
gemini.POST("/models/*modelAction", h.Gateway.GeminiV1BetaModels)
|
|
}
|
|
|
|
// OpenAI Responses API(不带v1前缀的别名)— auto-route based on group platform
|
|
responsesHandler := func(c *gin.Context) {
|
|
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
|
h.OpenAIGateway.Responses(c)
|
|
return
|
|
}
|
|
h.Gateway.Responses(c)
|
|
}
|
|
// 根路径别名共用中间件链:白名单准入在 apiKeyAuth 之后、compositeTarget
|
|
// 之前,避免逐条路由手工维护链导致漏挂。
|
|
rootRoute := func(method, path string, limit gin.HandlerFunc, handler gin.HandlerFunc) {
|
|
r.Handle(method, path, limit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic, handler)
|
|
}
|
|
for _, prefix := range []string{"/api/v3", "/v3", "/v1", ""} {
|
|
rootRoute(http.MethodPost, prefix+"/contents/generations/tasks", bodyLimit, h.OpenAIGateway.SeedanceTasks)
|
|
rootRoute(http.MethodGet, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks)
|
|
rootRoute(http.MethodDelete, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks)
|
|
}
|
|
rootRoute(http.MethodPost, "/responses", bodyLimit, responsesHandler)
|
|
rootRoute(http.MethodPost, "/responses/*subpath", bodyLimit, guardResponsesSubpath(responsesHandler))
|
|
rootRoute(http.MethodPost, "/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
|
|
rootRoute(http.MethodGet, "/responses", bodyLimit, func(c *gin.Context) {
|
|
h.OpenAIGateway.ResponsesWebSocket(c)
|
|
})
|
|
rootRoute(http.MethodGet, "/models", bodyLimit, modelsHandler)
|
|
rootRoute(http.MethodGet, "/models/:model", bodyLimit, h.Gateway.Models)
|
|
rootRoute(http.MethodPost, "/messages/count_tokens", bodyLimit, countTokensHandler)
|
|
codexDirect := r.Group("/backend-api/codex")
|
|
codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic)
|
|
{
|
|
codexDirect.POST("/realtime/calls", h.OpenAIGateway.Live)
|
|
codexDirect.GET("/:call_id", h.OpenAIGateway.LiveSideband)
|
|
codexDirect.POST("/responses", responsesHandler)
|
|
codexDirect.POST("/responses/*subpath", guardResponsesSubpath(responsesHandler))
|
|
codexDirect.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
|
|
codexDirect.GET("/responses", func(c *gin.Context) {
|
|
h.OpenAIGateway.ResponsesWebSocket(c)
|
|
})
|
|
codexDirect.GET("/models", codexModelsHandler)
|
|
}
|
|
// OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform
|
|
rootRoute(http.MethodPost, "/chat/completions", bodyLimit, func(c *gin.Context) {
|
|
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
|
h.OpenAIGateway.ChatCompletions(c)
|
|
return
|
|
}
|
|
h.Gateway.ChatCompletions(c)
|
|
})
|
|
rootRoute(http.MethodPost, "/embeddings", textBodyLimit, func(c *gin.Context) {
|
|
if !isOpenAIOnlyEndpointGatewayPlatform(c) {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{
|
|
"error": gin.H{
|
|
"type": "not_found_error",
|
|
"message": "Embeddings API is not supported for this platform",
|
|
},
|
|
})
|
|
return
|
|
}
|
|
h.OpenAIGateway.Embeddings(c)
|
|
})
|
|
rootRoute(http.MethodPost, "/images/generations", bodyLimit, imagesHandler)
|
|
rootRoute(http.MethodPost, "/images/edits", bodyLimit, imagesHandler)
|
|
rootRoute(http.MethodPost, "/images/generations/async", bodyLimit, h.AsyncImage.Submit)
|
|
rootRoute(http.MethodPost, "/images/edits/async", bodyLimit, h.AsyncImage.Submit)
|
|
rootRoute(http.MethodGet, "/images/tasks/:task_id", bodyLimit, h.AsyncImage.Get)
|
|
rootRoute(http.MethodPost, "/videos", bodyLimit, videoGenerationHandler)
|
|
rootRoute(http.MethodPost, "/videos/generations", bodyLimit, videoGenerationHandler)
|
|
rootRoute(http.MethodPost, "/videos/edits", bodyLimit, videoEditHandler)
|
|
rootRoute(http.MethodPost, "/videos/extensions", bodyLimit, videoExtensionHandler)
|
|
rootRoute(http.MethodGet, "/videos/generations/:request_id/content", bodyLimit, videoContentHandler)
|
|
rootRoute(http.MethodGet, "/videos/edits/:request_id/content", bodyLimit, videoContentHandler)
|
|
rootRoute(http.MethodGet, "/videos/extensions/:request_id/content", bodyLimit, videoContentHandler)
|
|
rootRoute(http.MethodGet, "/videos/generations/:request_id", bodyLimit, videoStatusHandler)
|
|
rootRoute(http.MethodGet, "/videos/edits/:request_id", bodyLimit, videoStatusHandler)
|
|
rootRoute(http.MethodGet, "/videos/extensions/:request_id", bodyLimit, videoStatusHandler)
|
|
rootRoute(http.MethodGet, "/videos/:request_id", bodyLimit, videoStatusHandler)
|
|
rootRoute(http.MethodGet, "/videos/:request_id/content", bodyLimit, videoContentHandler)
|
|
|
|
rootVoiceHandler := func(endpoint string) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
if getGroupPlatform(c) != service.PlatformGrok {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}})
|
|
return
|
|
}
|
|
h.OpenAIGateway.GrokVoice(c, endpoint)
|
|
}
|
|
}
|
|
rootRoute(http.MethodPost, "/tts", bodyLimit, rootVoiceHandler("tts"))
|
|
rootRoute(http.MethodPost, "/stt", bodyLimit, rootVoiceHandler("stt"))
|
|
rootRoute(http.MethodPost, "/custom-voices", bodyLimit, rootVoiceHandler("custom-voices"))
|
|
rootCustomVoicePathHandler := func(c *gin.Context) {
|
|
if getGroupPlatform(c) != service.PlatformGrok {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Voice API is not supported for this platform"}})
|
|
return
|
|
}
|
|
h.OpenAIGateway.GrokVoice(c, grokCustomVoiceEndpoint(c))
|
|
}
|
|
rootRoute(http.MethodGet, "/custom-voices", bodyLimit, rootVoiceHandler("custom-voices"))
|
|
rootRoute(http.MethodGet, "/custom-voices/:voice_id/audio", bodyLimit, rootCustomVoicePathHandler)
|
|
rootRoute(http.MethodGet, "/custom-voices/:voice_id", bodyLimit, rootCustomVoicePathHandler)
|
|
rootRoute(http.MethodPatch, "/custom-voices/:voice_id", bodyLimit, rootCustomVoicePathHandler)
|
|
rootRoute(http.MethodDelete, "/custom-voices/:voice_id", bodyLimit, rootCustomVoicePathHandler)
|
|
rootRoute(http.MethodGet, "/realtime", bodyLimit, func(c *gin.Context) {
|
|
if getGroupPlatform(c) != service.PlatformGrok {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Realtime API is not supported for this platform"}})
|
|
return
|
|
}
|
|
h.OpenAIGateway.GrokRealtime(c)
|
|
})
|
|
rootRoute(http.MethodPost, "/web_search", bodyLimit, func(c *gin.Context) {
|
|
if getGroupPlatform(c) != service.PlatformGrok {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Web Search API is not supported for this platform"}})
|
|
return
|
|
}
|
|
h.Gateway.WebSearch(c)
|
|
})
|
|
rootRoute(http.MethodPost, "/x_search", bodyLimit, func(c *gin.Context) {
|
|
if getGroupPlatform(c) != service.PlatformGrok {
|
|
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
|
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "X Search API is not supported for this platform"}})
|
|
return
|
|
}
|
|
h.Gateway.XSearch(c)
|
|
})
|
|
|
|
// Antigravity 模型列表
|
|
r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels)
|
|
|
|
// Antigravity 专用路由(仅使用 antigravity 账户,不混合调度)
|
|
antigravityV1 := r.Group("/antigravity/v1")
|
|
antigravityV1.Use(bodyLimit)
|
|
antigravityV1.Use(clientRequestID)
|
|
antigravityV1.Use(opsErrorLogger)
|
|
antigravityV1.Use(endpointNorm)
|
|
antigravityV1.Use(middleware.ForcePlatform(service.PlatformAntigravity))
|
|
antigravityV1.Use(gin.HandlerFunc(apiKeyAuth))
|
|
antigravityV1.Use(groupModelAllowlist)
|
|
antigravityV1.Use(requireGroupAnthropic)
|
|
{
|
|
antigravityV1.POST("/messages", h.Gateway.Messages)
|
|
antigravityV1.POST("/messages/count_tokens", h.Gateway.CountTokens)
|
|
antigravityV1.GET("/models", h.Gateway.AntigravityModels)
|
|
antigravityV1.GET("/usage", h.Gateway.Usage)
|
|
}
|
|
|
|
antigravityV1Beta := r.Group("/antigravity/v1beta")
|
|
antigravityV1Beta.Use(bodyLimit)
|
|
antigravityV1Beta.Use(clientRequestID)
|
|
antigravityV1Beta.Use(opsErrorLogger)
|
|
antigravityV1Beta.Use(endpointNorm)
|
|
antigravityV1Beta.Use(middleware.ForcePlatform(service.PlatformAntigravity))
|
|
antigravityV1Beta.Use(middleware.APIKeyAuthWithSubscriptionGoogle(apiKeyService, subscriptionService, cfg))
|
|
antigravityV1Beta.Use(groupModelAllowlist)
|
|
antigravityV1Beta.Use(requireGroupGoogle)
|
|
{
|
|
antigravityV1Beta.GET("/models", h.Gateway.GeminiV1BetaListModels)
|
|
antigravityV1Beta.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
|
|
antigravityV1Beta.POST("/models/*modelAction", h.Gateway.GeminiV1BetaModels)
|
|
}
|
|
|
|
}
|
|
|
|
func dispatchCodexModelsGateway(c *gin.Context, openAIHandler, generatedHandler gin.HandlerFunc) {
|
|
if getGroupPlatform(c) == service.PlatformOpenAI {
|
|
openAIHandler(c)
|
|
return
|
|
}
|
|
generatedHandler(c)
|
|
}
|
|
|
|
// getGroupPlatform extracts the group platform from the API Key stored in context.
|
|
func getGroupPlatform(c *gin.Context) string {
|
|
apiKey, ok := middleware.GetAPIKeyFromContext(c)
|
|
if !ok || apiKey.Group == nil {
|
|
return ""
|
|
}
|
|
if apiKey.Group.Platform == service.PlatformComposite {
|
|
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
|
|
return platform
|
|
}
|
|
}
|
|
return apiKey.Group.Platform
|
|
}
|
|
|
|
func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) gin.HandlerFunc {
|
|
if resolver == nil {
|
|
resolver = service.NewCompositeRouteResolver(nil)
|
|
}
|
|
return func(c *gin.Context) {
|
|
apiKey, ok := middleware.GetAPIKeyFromContext(c)
|
|
if !ok || apiKey == nil || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformComposite {
|
|
c.Next()
|
|
return
|
|
}
|
|
if c.Request == nil || c.Request.Method == http.MethodGet {
|
|
c.Next()
|
|
return
|
|
}
|
|
|
|
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
|
|
if err != nil {
|
|
status := http.StatusBadRequest
|
|
message := "Failed to read request body"
|
|
var maxErr *http.MaxBytesError
|
|
if errors.As(err, &maxErr) {
|
|
status = http.StatusRequestEntityTooLarge
|
|
message = "Request body is too large"
|
|
}
|
|
c.JSON(status, gin.H{"error": gin.H{"type": "invalid_request_error", "message": message}})
|
|
c.Abort()
|
|
return
|
|
}
|
|
|
|
// Live 入口按 session.model 分发并改写(与白名单准入、Live handler 的
|
|
// 解析保持一致),避免顶层 model 别名与 session 模型不一致时改错对象。
|
|
routePath := c.FullPath()
|
|
model := requestmodel.FromBodyForRoute(routePath, c.GetHeader("Content-Type"), body)
|
|
if model != "" {
|
|
decision, err := resolver.Resolve(c.Request.Context(), apiKey.Group.ID, model, compositeRouteEndpointForPath(c.Request.URL.Path))
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"type": "server_error", "message": "Failed to resolve composite model route"}})
|
|
c.Abort()
|
|
return
|
|
}
|
|
if decision.Matched {
|
|
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
|
|
if upstreamModel := strings.TrimSpace(decision.UpstreamModel); upstreamModel != "" && upstreamModel != model && gjson.ValidBytes(body) {
|
|
if _, modelPath := requestmodel.JSONModelPathForRoute(routePath, body); modelPath != "" {
|
|
if rewritten, rewriteErr := sjson.SetBytes(body, modelPath, upstreamModel); rewriteErr == nil {
|
|
body = rewritten
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
requestmodel.ResetRequestBody(c.Request, body)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func compositeGeminiTargetPlatformMiddleware(resolver *service.CompositeRouteResolver) gin.HandlerFunc {
|
|
if resolver == nil {
|
|
resolver = service.NewCompositeRouteResolver(nil)
|
|
}
|
|
return func(c *gin.Context) {
|
|
apiKey, ok := middleware.GetAPIKeyFromContext(c)
|
|
if ok && apiKey != nil && apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
|
|
model := compositeGeminiModelFromParams(c)
|
|
if model != "" {
|
|
decision, err := resolver.Resolve(c.Request.Context(), apiKey.Group.ID, model, service.CompositeRouteEndpointGemini)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": gin.H{"type": "server_error", "message": "Failed to resolve composite model route"}})
|
|
c.Abort()
|
|
return
|
|
}
|
|
if decision.Matched {
|
|
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
|
|
}
|
|
}
|
|
if _, resolved := service.ResolvedTargetPlatformFromContext(c.Request.Context()); !resolved {
|
|
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformGemini))
|
|
}
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// grokCustomVoiceEndpoint derives the upstream Voice endpoint for the
|
|
// /custom-voices/:voice_id[/audio] routes.
|
|
//
|
|
// The /audio suffix must be decided from the matched route template, not from
|
|
// the raw URL path: a voice literally named "audio" makes GET
|
|
// /custom-voices/audio match /custom-voices/:voice_id, and a raw-path suffix
|
|
// check would rewrite it to custom-voices/audio/audio — turning a profile
|
|
// lookup into an audio download.
|
|
func grokCustomVoiceEndpoint(c *gin.Context) string {
|
|
endpoint := "custom-voices/" + c.Param("voice_id")
|
|
if strings.HasSuffix(c.FullPath(), "/:voice_id/audio") {
|
|
endpoint += "/audio"
|
|
}
|
|
return endpoint
|
|
}
|
|
|
|
func compositeGeminiModelFromParams(c *gin.Context) string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
if model := strings.TrimSpace(c.Param("model")); model != "" {
|
|
return model
|
|
}
|
|
modelAction := strings.TrimPrefix(strings.TrimSpace(c.Param("modelAction")), "/")
|
|
if modelAction == "" {
|
|
return ""
|
|
}
|
|
if idx := strings.LastIndex(modelAction, ":"); idx >= 0 {
|
|
return strings.TrimSpace(modelAction[:idx])
|
|
}
|
|
return modelAction
|
|
}
|
|
|
|
func compositeRouteEndpointForPath(path string) string {
|
|
switch {
|
|
case strings.Contains(path, "/messages/count_tokens"):
|
|
return service.CompositeRouteEndpointCountTokens
|
|
case strings.Contains(path, "/messages"):
|
|
return service.CompositeRouteEndpointMessages
|
|
case strings.Contains(path, "/responses"),
|
|
strings.Contains(path, "/alpha/search"),
|
|
strings.Contains(path, "/realtime/calls"),
|
|
strings.HasSuffix(strings.TrimRight(path, "/"), "/live"):
|
|
return service.CompositeRouteEndpointResponses
|
|
case strings.Contains(path, "/chat/completions"):
|
|
return service.CompositeRouteEndpointChatCompletions
|
|
case strings.Contains(path, "/embeddings"):
|
|
return service.CompositeRouteEndpointEmbeddings
|
|
case strings.Contains(path, "/images/"):
|
|
return service.CompositeRouteEndpointImages
|
|
case strings.Contains(path, "/v1beta/"):
|
|
return service.CompositeRouteEndpointGemini
|
|
default:
|
|
return service.CompositeRouteEndpointAny
|
|
}
|
|
}
|