Files
sub2api/backend/internal/handler/endpoint.go
T
feeeei d5a802f84a feat(gemini): 支持显式上下文缓存(AI Studio / Vertex / 自建网关上游)
新增 /v1beta/cachedContents 的创建、列表、查询、更新有效期与删除接口。
参与账号:官方地址的 AI Studio API Key 账号、Vertex service account 账号,
以及开启"上游支持显式缓存"的自定义地址 API Key 账号。

- 网关自发缓存 ID,绑定上游资源名、持有账号与创建它的 API Key
  (迁移 243 gemini_cached_contents);list/get 只读本地记录
- 引用缓存的 generateContent / streamGenerateContent / countTokens
  改写为上游资源名,只发往持有缓存的账号,不参与会话粘性、不换号;
  拒绝重复键与 snake_case 写法;在持有账号上重试耗尽后,
  上游 429 / 5xx 原样透传,401 / 403 映射为 502
- 计费:创建时一次性收取缓存 token 输入费与整个有效期的存储费;
  延长按库中实际推进的时长收费,纯存储费不算一次请求;缩短与删除不退费
- 存储单价随计费模型取价,来自定价覆盖文件字段
  cache_storage_cost_per_token_per_hour,未配置的模型拒绝创建;
  存储费同时计入账号统计成本,渠道按 token 重算成本时叠加且不重复计入
- 扣费前校验:创建在上游建成后按确定金额校验余额 / 订阅额度 /
  API Key 额度并登记不封顶的在途预留,不足时删除上游缓存、只收输入费;
  延长在调用上游前按增量时长校验
- 有效期上限 gateway.gemini_cached_content_max_ttl_seconds,默认 86400,
  有效期向上取整到秒;创建请求保留数字原文、兼容 snake_case 元数据字段
- 上游调用与落库同客户端连接脱钩;2xx 不可用响应不换号并删除已建缓存
- 缓存不存在:AI Studio 的 403、Vertex 生成请求的 400 与缓存资源接口的
  404 统一识别;原生转发(含流式)不重试、不换号、不改账号状态,
  向客户端返回标准 403,不暴露上游缓存资源 ID,上游原始错误登记到 ops;
  PATCH 遇上游 404 移除本地记录并返回 403,DELETE 视为成功
- 自建网关上游:账号开关 explicit_cache_upstream,缓存接口发往账号自身
  base_url;可选 explicit_cache_upstream_max_ttl_seconds 对齐上游 TTL 上限,
  显式 TTL 超限时创建跳过该账号(全部超限返回 400),未显式时按上限创建,
  PATCH 取本站与账号上限的较小值;绑定缓存的请求在网关账号上收到
  429 / 5xx 时不重试、不限流该账号;前端创建 / 编辑账号弹窗新增开关与上限输入框
- 用量与 ops 端点统计:带缓存 ID 的路径归一化为 /v1beta/cachedContents,
  缓存操作的上游端点记为 /v1beta/cachedContents
2026-10-10 23:37:47 +08:00

365 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package 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"
EndpointSystemOne = "/v1/systemone"
EndpointChatCompletions = "/v1/chat/completions"
EndpointEmbeddings = "/v1/embeddings"
EndpointAlphaSearch = "/v1/alpha/search"
EndpointResponses = "/v1/responses"
EndpointResponsesCompact = "/v1/responses/compact"
EndpointResponsesInputTokens = "/v1/responses/input_tokens"
EndpointImagesGenerations = "/v1/images/generations"
EndpointImagesEdits = "/v1/images/edits"
EndpointImageTasks = "/v1/images/tasks"
EndpointVideosGenerations = "/v1/videos/generations"
EndpointVideosEdits = "/v1/videos/edits"
EndpointVideosExtensions = "/v1/videos/extensions"
EndpointVideos = "/v1/videos"
EndpointSeedanceTasks = "/api/v3/contents/generations/tasks"
EndpointGeminiModels = "/v1beta/models"
EndpointGeminiCachedContents = "/v1beta/cachedContents"
)
const EndpointAntigravityGenerateContent = "/v1internal:streamGenerateContent"
// gin.Context keys used by the middleware and helpers below.
const (
ctxKeyInboundEndpoint = "_gateway_inbound_endpoint"
ctxKeyActualUpstreamEndpoint = "_gateway_actual_upstream_endpoint"
)
// ──────────────────────────────────────────────────────────
// Normalization functions
// ──────────────────────────────────────────────────────────
// NormalizeInboundEndpoint maps a raw request path (which may carry
// prefixes like /antigravity, /openai) 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"
// "/v1beta/cachedContents/abc" → "/v1beta/cachedContents"
//
// The OpenAI Responses API is also exposed via a few bare/alias
// routes that do not carry a "/v1/" prefix (top-level bare route and
// the Codex direct route). "/responses/compact" (and "/backend-api/
// codex/responses/compact") is a distinct client endpoint — the
// "compact" client — and is normalized to its OWN canonical inbound
// endpoint, EndpointResponsesCompact, rather than being folded into
// the root Responses endpoint. Any other subpath under the bare/alias
// roots (i.e. not "compact" itself or nested under it) remains a
// subresource suffix of the root Responses endpoint:
//
// "/v1/responses/compact" → EndpointResponsesCompact
// "/v1/responses/compact/detail" → EndpointResponsesCompact
// "/openai/v1/responses/compact" → EndpointResponsesCompact
// "/openai/v1/responses/compact/detail" → EndpointResponsesCompact
// "/responses/compact" → EndpointResponsesCompact
// "/responses/compact/detail" → EndpointResponsesCompact
// "/backend-api/codex/responses/compact" → EndpointResponsesCompact
// "/backend-api/codex/responses/compact/detail" → EndpointResponsesCompact
// "/v1/responses" → EndpointResponses
// "/openai/v1/responses" → EndpointResponses
// "/responses" → EndpointResponses
// "/backend-api/codex/responses" → EndpointResponses
//
// The compact check MUST be evaluated before the root Responses check,
// otherwise "/v1/responses" (a prefix of "/v1/responses/compact")
// would erroneously match first.
func NormalizeInboundEndpoint(path string) string {
path = strings.TrimSpace(path)
switch {
case strings.Contains(path, "/contents/generations/tasks"):
return EndpointSeedanceTasks
case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path):
return EndpointResponsesInputTokens
case strings.Contains(path, 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):
return EndpointChatCompletions
case strings.Contains(path, EndpointMessages):
return EndpointMessages
case strings.Contains(path, EndpointSystemOne):
return EndpointSystemOne
case strings.Contains(path, EndpointImagesGenerations) || strings.Contains(path, "/images/generations"):
return EndpointImagesGenerations
case strings.Contains(path, EndpointImagesEdits) || strings.Contains(path, "/images/edits"):
return EndpointImagesEdits
case strings.Contains(path, EndpointImageTasks) || strings.Contains(path, "/images/tasks/"):
return EndpointImageTasks
case strings.Contains(path, EndpointVideosGenerations) || strings.Contains(path, "/videos/generations"):
return EndpointVideosGenerations
case strings.Contains(path, EndpointVideosEdits) || strings.Contains(path, "/videos/edits"):
return EndpointVideosEdits
case strings.Contains(path, EndpointVideosExtensions) || strings.Contains(path, "/videos/extensions"):
return EndpointVideosExtensions
case strings.Contains(path, EndpointVideos) || strings.Contains(path, "/videos/"):
return EndpointVideos
case strings.Contains(path, EndpointResponsesCompact) || isResponsesCompactAliasPath(path):
return EndpointResponsesCompact
case strings.Contains(path, EndpointResponses) || isResponsesRootAliasPath(path):
return EndpointResponses
case strings.Contains(path, EndpointGeminiModels):
return EndpointGeminiModels
case strings.Contains(path, EndpointGeminiCachedContents):
return EndpointGeminiCachedContents
default:
return path
}
}
func isResponsesInputTokensAliasPath(path string) bool {
trimmed := strings.TrimRight(strings.TrimSpace(path), "/")
if trimmed == "" {
return false
}
return isBareOrSubpathOf(trimmed, "/responses/input_tokens") ||
isBareOrSubpathOf(trimmed, "/backend-api/codex/responses/input_tokens")
}
// isResponsesCompactAliasPath reports whether path is the bare/alias
// "compact" client endpoint — i.e. it is rooted at "/responses/compact"
// or "/backend-api/codex/responses/compact" (bare routes that serve
// the OpenAI Responses API "compact" client without a "/v1/" prefix),
// or any subpath nested under either of those roots:
//
// - "/responses/compact" (bare route, compact client)
// - "/responses/compact/*subpath" (nested, e.g. "/responses/compact/detail")
// - "/backend-api/codex/responses/compact" (Codex direct route, compact client)
// - "/backend-api/codex/responses/compact/*subpath" (nested, e.g.
// "/backend-api/codex/responses/compact/detail")
//
// This MUST be checked before isResponsesRootAliasPath, since
// "/responses" is a prefix of "/responses/compact".
func isResponsesCompactAliasPath(path string) bool {
trimmed := strings.TrimRight(strings.TrimSpace(path), "/")
if trimmed == "" {
return false
}
return isBareOrSubpathOf(trimmed, "/responses/compact") || isBareOrSubpathOf(trimmed, "/backend-api/codex/responses/compact")
}
// isResponsesRootAliasPath reports whether path is one of the bare/alias
// routes that serve the root OpenAI Responses API without a "/v1/"
// prefix, or any non-"compact" subpath registered under them:
//
// - "/responses" (top-level bare route)
// - "/responses/*subpath" (any subpath other than "compact",
// since "compact" is its own distinct inbound endpoint)
// - "/backend-api/codex/responses" (Codex direct route)
// - "/backend-api/codex/responses/*subpath" (any subpath other than
// "compact")
//
// Only the top-level bare route and the Codex direct route (and their
// subpaths) are recognized here — this deliberately does NOT generalize
// to any path merely ending in "/responses" (e.g. an unrelated
// "/foo/responses" must not match).
func isResponsesRootAliasPath(path string) bool {
trimmed := strings.TrimRight(strings.TrimSpace(path), "/")
if trimmed == "" {
return false
}
return isBareOrSubpathOf(trimmed, "/responses") || isBareOrSubpathOf(trimmed, "/backend-api/codex/responses")
}
// isBareOrSubpathOf reports whether path is exactly root, or a subpath
// rooted at root (i.e. root followed by "/"). This anchors the match
// at the start of path so it cannot match paths where root appears
// nested under some other unrelated prefix.
func isBareOrSubpathOf(path, root string) bool {
return path == root || strings.HasPrefix(path, root+"/")
}
// DeriveUpstreamEndpoint determines the upstream endpoint from the
// account platform and the normalized inbound endpoint.
//
// Platform-specific rules:
// - OpenAI and Grok text compatibility routes forward to /v1/responses
// (with optional subpath such as /v1/responses/compact preserved from
// the raw URL); native endpoints such as embeddings and alpha search
// retain their paths. Grok raw Chat requests override this through the
// forwarding result consumed by resolveOpenAIUpstreamEndpoint.
// - Anthropic → /v1/messages
// - Gemini → /v1beta/models(显式缓存管理接口保留 /v1beta/cachedContents)
// - Antigravity → /v1/messages (Claude) or gemini (Gemini)
// - 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, service.PlatformGrok:
if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointResponsesInputTokens || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos {
return inbound
}
// OpenAI forwards everything to the Responses API.
// Preserve subresource suffix (e.g. /v1/responses/compact,
// /v1/responses/compact/detail) as derived from the raw path.
if suffix := responsesSubpathSuffix(rawRequestPath); suffix != "" {
return EndpointResponses + suffix
}
// The raw path carried no derivable suffix (e.g. it was already
// normalized upstream, or the caller only has the canonical
// inbound endpoint available) — fall back to the canonical
// compact endpoint when that's what the inbound request was
// recognized as, so it isn't silently treated as the root
// Responses endpoint.
if inbound == EndpointResponsesCompact {
return EndpointResponsesCompact
}
return EndpointResponses
case service.PlatformAnthropic:
return EndpointMessages
case service.PlatformTypeSafe:
return EndpointSystemOne
case service.PlatformGemini:
if inbound == EndpointGeminiCachedContents {
return EndpointGeminiCachedContents
}
return EndpointGeminiModels
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 := ""
if c.Request != nil && c.Request.URL != nil {
path = c.Request.URL.Path
}
if path == "" {
path = c.FullPath()
}
c.Set(ctxKeyInboundEndpoint, NormalizeInboundEndpoint(path))
c.Next()
}
}
// ──────────────────────────────────────────────────────────
// Context helpers — used by handlers before building
// RecordUsageInput.
// ──────────────────────────────────────────────────────────
// 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.Request.URL.Path on the fly
// (preferring the raw request path over c.FullPath(), which collapses
// wildcard route patterns such as "/v1/responses/*subpath" and would
// otherwise mis-normalize concrete requests like "/v1/responses/compact"
// to the root Responses endpoint).
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 {
if c.Request != nil && c.Request.URL != nil {
path = c.Request.URL.Path
}
if path == "" {
path = c.FullPath()
}
}
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 {
// OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
// 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
// ForwardResult,不能把入站 /v1/responses 误报成上游端点。
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform) {
if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
return endpoint
}
}
if c != nil {
if value, ok := c.Get(ctxKeyActualUpstreamEndpoint); ok {
if endpoint, ok := value.(string); ok && endpoint != "" {
return endpoint
}
}
}
inbound := GetInboundEndpoint(c)
rawPath := ""
if c != nil && c.Request != nil && c.Request.URL != nil {
rawPath = c.Request.URL.Path
}
return DeriveUpstreamEndpoint(inbound, rawPath, platform)
}
func setActualUpstreamEndpoint(c *gin.Context, endpoint string) {
if c != nil {
c.Set(ctxKeyActualUpstreamEndpoint, strings.TrimSpace(endpoint))
}
}
func shouldUseAntigravityCompat(account *service.Account) bool {
return account != nil &&
account.Platform == service.PlatformAntigravity &&
account.Type == service.AccountTypeOAuth
}