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
717 lines
32 KiB
Go
717 lines
32 KiB
Go
package routes
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/Wei-Shaw/sub2api/internal/handler"
|
|
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
|
"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)
|
|
|
|
isOpenAIResponsesCompatibleGatewayPlatform := func(c *gin.Context) bool {
|
|
switch getGroupPlatform(c) {
|
|
case service.PlatformOpenAI, service.PlatformGrok,
|
|
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
|
|
// 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)与 openai/grok 一样经 OpenAI 网关转发。
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
countTokensHandler := func(c *gin.Context) {
|
|
switch getGroupPlatform(c) {
|
|
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
|
|
h.OpenAIGateway.CountTokens(c)
|
|
case service.PlatformGrok:
|
|
h.OpenAIGateway.GrokCountTokens(c)
|
|
default:
|
|
h.Gateway.CountTokens(c)
|
|
}
|
|
}
|
|
modelsHandler := func(c *gin.Context) {
|
|
if c.Query("client_version") != "" {
|
|
switch getGroupPlatform(c) {
|
|
case service.PlatformOpenAI, service.PlatformComposite:
|
|
h.OpenAIGateway.CodexModels(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) {
|
|
if getGroupPlatform(c) == service.PlatformGrok {
|
|
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(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)
|
|
})
|
|
// /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)
|
|
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(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)
|
|
}
|
|
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler)
|
|
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, guardResponsesSubpath(responsesHandler))
|
|
r.POST("/alpha/search", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.OpenAIGateway.AlphaSearch)
|
|
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
|
|
h.OpenAIGateway.ResponsesWebSocket(c)
|
|
})
|
|
r.GET("/models", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, modelsHandler)
|
|
r.POST("/messages/count_tokens", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, countTokensHandler)
|
|
codexDirect := r.Group("/backend-api/codex")
|
|
codexDirect.Use(bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), 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", h.OpenAIGateway.CodexModels)
|
|
}
|
|
// OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform
|
|
r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
|
|
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
|
h.OpenAIGateway.ChatCompletions(c)
|
|
return
|
|
}
|
|
h.Gateway.ChatCompletions(c)
|
|
})
|
|
r.POST("/embeddings", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, 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)
|
|
})
|
|
r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, imagesHandler)
|
|
r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, imagesHandler)
|
|
r.POST("/images/generations/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit)
|
|
r.POST("/images/edits/async", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Submit)
|
|
r.GET("/images/tasks/:task_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.AsyncImage.Get)
|
|
r.POST("/videos", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoGenerationHandler)
|
|
r.POST("/videos/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoGenerationHandler)
|
|
r.POST("/videos/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoEditHandler)
|
|
r.POST("/videos/extensions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoExtensionHandler)
|
|
r.GET("/videos/generations/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler)
|
|
r.GET("/videos/edits/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler)
|
|
r.GET("/videos/extensions/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoContentHandler)
|
|
r.GET("/videos/generations/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler)
|
|
r.GET("/videos/edits/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler)
|
|
r.GET("/videos/extensions/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler)
|
|
r.GET("/videos/:request_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, videoStatusHandler)
|
|
r.GET("/videos/:request_id/content", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, 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)
|
|
}
|
|
}
|
|
r.POST("/tts", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("tts"))
|
|
r.POST("/stt", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("stt"))
|
|
r.POST("/custom-voices", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, 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))
|
|
}
|
|
r.GET("/custom-voices", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootVoiceHandler("custom-voices"))
|
|
r.GET("/custom-voices/:voice_id/audio", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler)
|
|
r.GET("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler)
|
|
r.PATCH("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler)
|
|
r.DELETE("/custom-voices/:voice_id", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, rootCustomVoicePathHandler)
|
|
r.GET("/realtime", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, 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)
|
|
})
|
|
r.POST("/web_search", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, 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)
|
|
})
|
|
r.POST("/x_search", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, 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(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(requireGroupGoogle)
|
|
{
|
|
antigravityV1Beta.GET("/models", h.Gateway.GeminiV1BetaListModels)
|
|
antigravityV1Beta.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
|
|
antigravityV1Beta.POST("/models/*modelAction", h.Gateway.GeminiV1BetaModels)
|
|
}
|
|
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
model := compositeRequestModelFromBody(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 := compositeJSONRequestModel(body); modelPath != "" {
|
|
if rewritten, rewriteErr := sjson.SetBytes(body, modelPath, upstreamModel); rewriteErr == nil {
|
|
body = rewritten
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
resetRequestBody(c, body)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func compositeRequestModelFromBody(contentType string, body []byte) string {
|
|
if model, _ := compositeJSONRequestModel(body); model != "" {
|
|
return model
|
|
}
|
|
return compositeMultipartModelFromBody(contentType, body)
|
|
}
|
|
|
|
func compositeJSONRequestModel(body []byte) (string, string) {
|
|
for _, path := range []string{"model", "session.model"} {
|
|
model := gjson.GetBytes(body, path)
|
|
if model.Type != gjson.String {
|
|
continue
|
|
}
|
|
if value := strings.TrimSpace(model.String()); value != "" {
|
|
return value, path
|
|
}
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func compositeMultipartModelFromBody(contentType string, body []byte) string {
|
|
mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(contentType))
|
|
if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") {
|
|
return ""
|
|
}
|
|
boundary := strings.TrimSpace(params["boundary"])
|
|
if boundary == "" {
|
|
return ""
|
|
}
|
|
reader := multipart.NewReader(bytes.NewReader(body), boundary)
|
|
for {
|
|
part, err := reader.NextPart()
|
|
if errors.Is(err, io.EOF) {
|
|
return ""
|
|
}
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
fieldName := part.FormName()
|
|
if part.FileName() != "" || (fieldName != "model" && fieldName != "session") {
|
|
continue
|
|
}
|
|
data, err := io.ReadAll(part)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
switch fieldName {
|
|
case "model":
|
|
return strings.TrimSpace(string(data))
|
|
case "session":
|
|
if model, _ := compositeJSONRequestModel(data); model != "" {
|
|
return model
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 resetRequestBody(c *gin.Context, body []byte) {
|
|
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
|
c.Request.ContentLength = int64(len(body))
|
|
c.Request.Header.Set("Content-Length", strconv.Itoa(len(body)))
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|