mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
新增 /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
365 lines
16 KiB
Go
365 lines
16 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"
|
||
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
|
||
}
|