mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
Introduce endpoint.go with shared constants, NormalizeInboundEndpoint, DeriveUpstreamEndpoint, InboundEndpointMiddleware, and context helpers. This replaces the two separate normalization implementations (OpenAI and Gateway) with a single source of truth. Includes comprehensive test coverage. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
175 lines
6.4 KiB
Go
175 lines
6.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// Canonical inbound / upstream endpoint paths.
|
|
// All normalization and derivation reference this single set
|
|
// of constants — add new paths HERE when a new API surface
|
|
// is introduced.
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
const (
|
|
EndpointMessages = "/v1/messages"
|
|
EndpointChatCompletions = "/v1/chat/completions"
|
|
EndpointResponses = "/v1/responses"
|
|
EndpointGeminiModels = "/v1beta/models"
|
|
)
|
|
|
|
// gin.Context keys used by the middleware and helpers below.
|
|
const (
|
|
ctxKeyInboundEndpoint = "_gateway_inbound_endpoint"
|
|
)
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// Normalization functions
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
// NormalizeInboundEndpoint maps a raw request path (which may carry
|
|
// prefixes like /antigravity, /openai, /sora) to its canonical form.
|
|
//
|
|
// "/antigravity/v1/messages" → "/v1/messages"
|
|
// "/v1/chat/completions" → "/v1/chat/completions"
|
|
// "/openai/v1/responses/foo" → "/v1/responses"
|
|
// "/v1beta/models/gemini:gen" → "/v1beta/models"
|
|
func NormalizeInboundEndpoint(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
switch {
|
|
case strings.Contains(path, EndpointChatCompletions):
|
|
return EndpointChatCompletions
|
|
case strings.Contains(path, EndpointMessages):
|
|
return EndpointMessages
|
|
case strings.Contains(path, EndpointResponses):
|
|
return EndpointResponses
|
|
case strings.Contains(path, EndpointGeminiModels):
|
|
return EndpointGeminiModels
|
|
default:
|
|
return path
|
|
}
|
|
}
|
|
|
|
// DeriveUpstreamEndpoint determines the upstream endpoint from the
|
|
// account platform and the normalized inbound endpoint.
|
|
//
|
|
// Platform-specific rules:
|
|
// - OpenAI always forwards to /v1/responses (with optional subpath
|
|
// such as /v1/responses/compact preserved from the raw URL).
|
|
// - Anthropic → /v1/messages
|
|
// - Gemini → /v1beta/models
|
|
// - Sora → /v1/chat/completions
|
|
// - Antigravity routes may target either Claude or Gemini, so the
|
|
// inbound endpoint is used to distinguish.
|
|
func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
|
|
inbound = strings.TrimSpace(inbound)
|
|
|
|
switch platform {
|
|
case service.PlatformOpenAI:
|
|
// OpenAI forwards everything to the Responses API.
|
|
// Preserve subresource suffix (e.g. /v1/responses/compact).
|
|
if suffix := responsesSubpathSuffix(rawRequestPath); suffix != "" {
|
|
return EndpointResponses + suffix
|
|
}
|
|
return EndpointResponses
|
|
|
|
case service.PlatformAnthropic:
|
|
return EndpointMessages
|
|
|
|
case service.PlatformGemini:
|
|
return EndpointGeminiModels
|
|
|
|
case service.PlatformSora:
|
|
return EndpointChatCompletions
|
|
|
|
case service.PlatformAntigravity:
|
|
// Antigravity accounts serve both Claude and Gemini.
|
|
if inbound == EndpointGeminiModels {
|
|
return EndpointGeminiModels
|
|
}
|
|
return EndpointMessages
|
|
}
|
|
|
|
// Unknown platform — fall back to inbound.
|
|
return inbound
|
|
}
|
|
|
|
// responsesSubpathSuffix extracts the part after "/responses" in a raw
|
|
// request path, e.g. "/openai/v1/responses/compact" → "/compact".
|
|
// Returns "" when there is no meaningful suffix.
|
|
func responsesSubpathSuffix(rawPath string) string {
|
|
trimmed := strings.TrimRight(strings.TrimSpace(rawPath), "/")
|
|
idx := strings.LastIndex(trimmed, "/responses")
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
suffix := trimmed[idx+len("/responses"):]
|
|
if suffix == "" || suffix == "/" {
|
|
return ""
|
|
}
|
|
if !strings.HasPrefix(suffix, "/") {
|
|
return ""
|
|
}
|
|
return suffix
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// Middleware
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
// InboundEndpointMiddleware normalizes the request path and stores the
|
|
// canonical inbound endpoint in gin.Context so that every handler in
|
|
// the chain can read it via GetInboundEndpoint.
|
|
//
|
|
// Apply this middleware to all gateway route groups.
|
|
func InboundEndpointMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
path := c.FullPath()
|
|
if path == "" && c.Request != nil && c.Request.URL != nil {
|
|
path = c.Request.URL.Path
|
|
}
|
|
c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(path))
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────
|
|
// Context helpers — used by handlers before building
|
|
// RecordUsageInput / RecordUsageLongContextInput.
|
|
// ──────────────────────────────────────────────────────────
|
|
|
|
// GetInboundEndpoint returns the canonical inbound endpoint stored by
|
|
// InboundEndpointMiddleware. If the middleware did not run (e.g. in
|
|
// tests), it falls back to normalizing c.FullPath() on the fly.
|
|
func GetInboundEndpoint(c *gin.Context) string {
|
|
if v, ok := c.Get(ctxKeyInboundEndpoint); ok {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
return s
|
|
}
|
|
}
|
|
// Fallback: normalize on the fly.
|
|
path := ""
|
|
if c != nil {
|
|
path = c.FullPath()
|
|
if path == "" && c.Request != nil && c.Request.URL != nil {
|
|
path = c.Request.URL.Path
|
|
}
|
|
}
|
|
return NormalizeInboundEndpoint(path)
|
|
}
|
|
|
|
// GetUpstreamEndpoint derives the upstream endpoint from the context
|
|
// and the account platform. Handlers call this after scheduling an
|
|
// account, passing account.Platform.
|
|
func GetUpstreamEndpoint(c *gin.Context, platform string) string {
|
|
inbound := GetInboundEndpoint(c)
|
|
rawPath := ""
|
|
if c != nil && c.Request != nil && c.Request.URL != nil {
|
|
rawPath = c.Request.URL.Path
|
|
}
|
|
return DeriveUpstreamEndpoint(inbound, rawPath, platform)
|
|
}
|