gateway_service.go 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "log/slog"
  10. "net/http"
  11. "os"
  12. "path/filepath"
  13. "regexp"
  14. "sort"
  15. "strconv"
  16. "strings"
  17. "sync/atomic"
  18. "time"
  19. "unsafe"
  20. "github.com/Wei-Shaw/sub2api/internal/config"
  21. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  22. "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
  23. "github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
  24. "github.com/cespare/xxhash/v2"
  25. gocache "github.com/patrickmn/go-cache"
  26. "github.com/tidwall/gjson"
  27. "github.com/tidwall/sjson"
  28. "golang.org/x/sync/singleflight"
  29. )
  30. const (
  31. claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
  32. claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true"
  33. stickySessionTTL = time.Hour // 粘性会话TTL
  34. defaultMaxLineSize = 500 * 1024 * 1024
  35. // Canonical Claude Code banner. Keep it EXACT (no trailing whitespace/newlines)
  36. // to match real Claude CLI traffic as closely as possible. When we need a visual
  37. // separator between system blocks, we add "\n\n" at concatenation time.
  38. claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude."
  39. // claudeCodeSystemPromptExpansion 是真实 Claude Code 主系统提示词中"与具体工具无关"
  40. // 的通用段落(身份/用途总述 + 安全声明 + URL 告警 + Tone and style),逐字取自真实
  41. // CLI(2.1.x 一致)。伪装路径用它把 system 块数从 2 提升到 3、体量贴近真实 CC,同时
  42. // 刻意排除 # Doing tasks / # Using your tools / # Executing actions 等会污染被代理
  43. // 用户行为的工具专属指令。
  44. claudeCodeSystemPromptExpansion = `You are an interactive agent that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
  45. IMPORTANT: Assist with authorized security testing, defensive security, CTF challenges, and educational contexts. Refuse requests for destructive techniques, DoS attacks, mass targeting, supply chain compromise, or detection evasion for malicious purposes. Dual-use security tools (C2 frameworks, credential testing, exploit development) require clear authorization context: pentesting engagements, CTF competitions, security research, or defensive use cases.
  46. IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
  47. # Tone and style
  48. - Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
  49. - Your responses should be short and concise.
  50. - When referencing specific functions or pieces of code include the pattern file_path:line_number to allow the user to easily navigate to the source code location.
  51. - When referencing GitHub issues or pull requests, use the owner/repo#123 format (e.g. anthropics/claude-code#100) so they render as clickable links.
  52. - Do not use a colon before tool calls. Your tool calls may not be shown directly in the output, so text like "Let me read the file:" followed by a read tool call should just be "Let me read the file." with a period.`
  53. maxCacheControlBlocks = 4 // Anthropic API 允许的最大 cache_control 块数量
  54. defaultUserGroupRateCacheTTL = 30 * time.Second
  55. defaultModelsListCacheTTL = 15 * time.Second
  56. postUsageBillingTimeout = 15 * time.Second
  57. claudeCodeNoopDeltaKeepaliveMinVersion = "2.1.193"
  58. debugGatewayBodyEnv = "SUB2API_DEBUG_GATEWAY_BODY"
  59. // 上游错误体只需要提取错误 JSON/日志摘要,默认 512KiB 避免错误风暴叠加大请求体。
  60. gatewayUpstreamErrorBodyReadLimit int64 = 512 << 10
  61. )
  62. const (
  63. claudeMimicDebugInfoKey = "claude_mimic_debug_info"
  64. )
  65. const (
  66. cacheTTLTarget5m = "5m"
  67. cacheTTLTarget1h = "1h"
  68. compositeModelOwnershipCachePrefix = "composite-owner|"
  69. )
  70. // ForceCacheBillingContextKey 强制缓存计费上下文键
  71. // 用于粘性会话切换时,将 input_tokens 转为 cache_read_input_tokens 计费
  72. type forceCacheBillingKeyType struct{}
  73. // accountWithLoad 账号与负载信息的组合,用于负载感知调度
  74. type accountWithLoad struct {
  75. account *Account
  76. loadInfo *AccountLoadInfo
  77. }
  78. var ForceCacheBillingContextKey = forceCacheBillingKeyType{}
  79. var (
  80. windowCostPrefetchCacheHitTotal atomic.Int64
  81. windowCostPrefetchCacheMissTotal atomic.Int64
  82. windowCostPrefetchBatchSQLTotal atomic.Int64
  83. windowCostPrefetchFallbackTotal atomic.Int64
  84. windowCostPrefetchErrorTotal atomic.Int64
  85. userGroupRateCacheHitTotal atomic.Int64
  86. userGroupRateCacheMissTotal atomic.Int64
  87. userGroupRateCacheLoadTotal atomic.Int64
  88. userGroupRateCacheSFSharedTotal atomic.Int64
  89. userGroupRateCacheFallbackTotal atomic.Int64
  90. modelsListCacheHitTotal atomic.Int64
  91. modelsListCacheMissTotal atomic.Int64
  92. modelsListCacheStoreTotal atomic.Int64
  93. // Deprecated: flusher_enabled=true 后不再增长(仅 flag=false 降级直写路径使用);新主路径见 FlusherMetrics。remove after 2026-09。
  94. // userPlatformQuotaDBIncrErrorTotal 统计 finalizePostUsageBilling 异步 goroutine
  95. // 中 IncrementUsageWithReset 失败次数。Redis 已成功累加 + DB 写失败意味着
  96. // Redis cache TTL 过期或被清后该笔 cost 会丢失(与实际消费偏差)。
  97. // oncall 通过 GatewayUserPlatformQuotaIncrStats() 暴露给 ops 面板做阈值告警。
  98. userPlatformQuotaDBIncrErrorTotal atomic.Int64
  99. // Deprecated: flusher_enabled=true 后不再增长(仅 flag=false 降级直写路径使用);新主路径见 FlusherMetrics。remove after 2026-09。
  100. // userPlatformQuotaDBIncrLegacyErrorTotal 统计 legacy postUsageBilling
  101. // (applyUsageBilling 在 repo==nil 时 fallback)路径下的失败次数;
  102. // 与 DB Incr 失败分开计数,便于区分"主路径暂时故障"vs"基础设施长期未配齐"。
  103. userPlatformQuotaDBIncrLegacyErrorTotal atomic.Int64
  104. // userPlatformQuotaSentinelSetCacheErrorTotal 统计 checkUserPlatformQuotaEligibility
  105. // 在 DB 无行时回填 sentinel cache entry 写 Redis 失败的次数(phase A)。
  106. userPlatformQuotaSentinelSetCacheErrorTotal atomic.Int64
  107. )
  108. func GatewayWindowCostPrefetchStats() (cacheHit, cacheMiss, batchSQL, fallback, errCount int64) {
  109. return windowCostPrefetchCacheHitTotal.Load(),
  110. windowCostPrefetchCacheMissTotal.Load(),
  111. windowCostPrefetchBatchSQLTotal.Load(),
  112. windowCostPrefetchFallbackTotal.Load(),
  113. windowCostPrefetchErrorTotal.Load()
  114. }
  115. func GatewayUserGroupRateCacheStats() (cacheHit, cacheMiss, load, singleflightShared, fallback int64) {
  116. return userGroupRateCacheHitTotal.Load(),
  117. userGroupRateCacheMissTotal.Load(),
  118. userGroupRateCacheLoadTotal.Load(),
  119. userGroupRateCacheSFSharedTotal.Load(),
  120. userGroupRateCacheFallbackTotal.Load()
  121. }
  122. func GatewayModelsListCacheStats() (cacheHit, cacheMiss, store int64) {
  123. return modelsListCacheHitTotal.Load(), modelsListCacheMissTotal.Load(), modelsListCacheStoreTotal.Load()
  124. }
  125. // GatewayUserPlatformQuotaIncrStats 返回 (mainPathErr, legacyPathErr, sentinelSetErr)。
  126. // mainPathErr:finalizePostUsageBilling 异步 goroutine 写 DB 失败累计次数;
  127. // legacyPathErr:postUsageBilling fallback 路径写 DB 失败累计次数;
  128. // sentinelSetErr:DB 无行时回填 sentinel cache entry 写 Redis 失败累计次数。
  129. // ops 监控面板可以按"持续上升斜率"做告警阈值。
  130. func GatewayUserPlatformQuotaIncrStats() (mainPathErr, legacyPathErr, sentinelSetErr int64) {
  131. return userPlatformQuotaDBIncrErrorTotal.Load(),
  132. userPlatformQuotaDBIncrLegacyErrorTotal.Load(),
  133. userPlatformQuotaSentinelSetCacheErrorTotal.Load()
  134. }
  135. // GatewayUserPlatformQuotaFlusherStats 暴露 flusher 运行指标供 ops/health 面板查询。
  136. func GatewayUserPlatformQuotaFlusherStats(f *UserPlatformQuotaUsageFlusher) map[string]int64 {
  137. if f == nil || f.metrics == nil {
  138. return nil
  139. }
  140. m := f.metrics
  141. return map[string]int64{
  142. "flush_success": m.FlushSuccessTotal.Load(),
  143. "flush_error": m.FlushErrorTotal.Load(),
  144. "flush_batch_size": m.FlushBatchSizeTotal.Load(),
  145. "flush_latency_ms_max": m.FlushLatencyMsMax.Load(),
  146. "dirty_readd": m.DirtyReaddTotal.Load(),
  147. "dirty_lost": m.DirtyLostTotal.Load(),
  148. "flush_fk_violation": m.FlushFKViolationTotal.Load(),
  149. }
  150. }
  151. func openAIStreamEventIsTerminal(data string) bool {
  152. trimmed := strings.TrimSpace(data)
  153. if trimmed == "" {
  154. return false
  155. }
  156. if trimmed == "[DONE]" {
  157. return true
  158. }
  159. return openAIStreamEventTypeIsTerminal(gjson.Get(trimmed, "type").String())
  160. }
  161. // openAIStreamEventIsTerminalWithType 复用已提取的 type,避免 SSE 热路径重复扫描 JSON。
  162. func openAIStreamEventIsTerminalWithType(data, eventType string) bool {
  163. trimmed := strings.TrimSpace(data)
  164. if trimmed == "" {
  165. return false
  166. }
  167. if trimmed == "[DONE]" {
  168. return true
  169. }
  170. return openAIStreamEventTypeIsTerminal(eventType)
  171. }
  172. func openAIStreamEventTypeIsTerminal(eventType string) bool {
  173. switch strings.TrimSpace(eventType) {
  174. case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled", "error":
  175. return true
  176. default:
  177. return false
  178. }
  179. }
  180. func anthropicStreamEventIsTerminal(eventName, data string) bool {
  181. if strings.EqualFold(strings.TrimSpace(eventName), "message_stop") {
  182. return true
  183. }
  184. trimmed := strings.TrimSpace(data)
  185. if trimmed == "" {
  186. return false
  187. }
  188. if trimmed == "[DONE]" {
  189. return true
  190. }
  191. return gjson.Get(trimmed, "type").String() == "message_stop"
  192. }
  193. func cloneStringSlice(src []string) []string {
  194. if len(src) == 0 {
  195. return nil
  196. }
  197. dst := make([]string, len(src))
  198. copy(dst, src)
  199. return dst
  200. }
  201. // IsForceCacheBilling 检查是否启用强制缓存计费
  202. func IsForceCacheBilling(ctx context.Context) bool {
  203. v, _ := ctx.Value(ForceCacheBillingContextKey).(bool)
  204. return v
  205. }
  206. // WithForceCacheBilling 返回带有强制缓存计费标记的上下文
  207. func WithForceCacheBilling(ctx context.Context) context.Context {
  208. return context.WithValue(ctx, ForceCacheBillingContextKey, true)
  209. }
  210. func (s *GatewayService) debugModelRoutingEnabled() bool {
  211. if s == nil {
  212. return false
  213. }
  214. return s.debugModelRouting.Load()
  215. }
  216. func (s *GatewayService) debugClaudeMimicEnabled() bool {
  217. if s == nil {
  218. return false
  219. }
  220. return s.debugClaudeMimic.Load()
  221. }
  222. func parseDebugEnvBool(raw string) bool {
  223. switch strings.ToLower(strings.TrimSpace(raw)) {
  224. case "1", "true", "yes", "on":
  225. return true
  226. default:
  227. return false
  228. }
  229. }
  230. func shortSessionHash(sessionHash string) string {
  231. if sessionHash == "" {
  232. return ""
  233. }
  234. if len(sessionHash) <= 8 {
  235. return sessionHash
  236. }
  237. return sessionHash[:8]
  238. }
  239. func redactAuthHeaderValue(v string) string {
  240. v = strings.TrimSpace(v)
  241. if v == "" {
  242. return ""
  243. }
  244. // Keep scheme for debugging, redact secret.
  245. if strings.HasPrefix(strings.ToLower(v), "bearer ") {
  246. return "Bearer [redacted]"
  247. }
  248. return "[redacted]"
  249. }
  250. func safeHeaderValueForLog(key string, v string) string {
  251. key = strings.ToLower(strings.TrimSpace(key))
  252. switch key {
  253. case "authorization", "x-api-key":
  254. return redactAuthHeaderValue(v)
  255. default:
  256. return strings.TrimSpace(v)
  257. }
  258. }
  259. func extractSystemPreviewFromBody(body []byte) string {
  260. if len(body) == 0 {
  261. return ""
  262. }
  263. sys := gjson.GetBytes(body, "system")
  264. if !sys.Exists() {
  265. return ""
  266. }
  267. switch {
  268. case sys.IsArray():
  269. for _, item := range sys.Array() {
  270. if !item.IsObject() {
  271. continue
  272. }
  273. if strings.EqualFold(item.Get("type").String(), "text") {
  274. if t := item.Get("text").String(); strings.TrimSpace(t) != "" {
  275. return t
  276. }
  277. }
  278. }
  279. return ""
  280. case sys.Type == gjson.String:
  281. return sys.String()
  282. default:
  283. return ""
  284. }
  285. }
  286. func buildClaudeMimicDebugLine(req *http.Request, body []byte, account *Account, tokenType string, mimicClaudeCode bool) string {
  287. if req == nil {
  288. return ""
  289. }
  290. // Only log a minimal fingerprint to avoid leaking user content.
  291. interesting := []string{
  292. "user-agent",
  293. "x-app",
  294. "anthropic-dangerous-direct-browser-access",
  295. "anthropic-version",
  296. "anthropic-beta",
  297. "x-stainless-lang",
  298. "x-stainless-package-version",
  299. "x-stainless-os",
  300. "x-stainless-arch",
  301. "x-stainless-runtime",
  302. "x-stainless-runtime-version",
  303. "x-stainless-retry-count",
  304. "x-stainless-timeout",
  305. "authorization",
  306. "x-api-key",
  307. "content-type",
  308. "accept",
  309. "x-stainless-helper-method",
  310. }
  311. h := make([]string, 0, len(interesting))
  312. for _, k := range interesting {
  313. if v := req.Header.Get(k); v != "" {
  314. h = append(h, fmt.Sprintf("%s=%q", k, safeHeaderValueForLog(k, v)))
  315. }
  316. }
  317. metaUserID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String())
  318. sysPreview := strings.TrimSpace(extractSystemPreviewFromBody(body))
  319. // Truncate preview to keep logs sane.
  320. if len(sysPreview) > 300 {
  321. sysPreview = sysPreview[:300] + "..."
  322. }
  323. sysPreview = strings.ReplaceAll(sysPreview, "\n", "\\n")
  324. sysPreview = strings.ReplaceAll(sysPreview, "\r", "\\r")
  325. aid := int64(0)
  326. aname := ""
  327. if account != nil {
  328. aid = account.ID
  329. aname = account.Name
  330. }
  331. return fmt.Sprintf(
  332. "url=%s account=%d(%s) tokenType=%s mimic=%t meta.user_id=%q system.preview=%q headers={%s}",
  333. req.URL.String(),
  334. aid,
  335. aname,
  336. tokenType,
  337. mimicClaudeCode,
  338. metaUserID,
  339. sysPreview,
  340. strings.Join(h, " "),
  341. )
  342. }
  343. func logClaudeMimicDebug(req *http.Request, body []byte, account *Account, tokenType string, mimicClaudeCode bool) {
  344. line := buildClaudeMimicDebugLine(req, body, account, tokenType, mimicClaudeCode)
  345. if line == "" {
  346. return
  347. }
  348. logger.LegacyPrintf("service.gateway", "[ClaudeMimicDebug] %s", line)
  349. }
  350. func isClaudeCodeCredentialScopeError(msg string) bool {
  351. m := strings.ToLower(strings.TrimSpace(msg))
  352. if m == "" {
  353. return false
  354. }
  355. return strings.Contains(m, "only authorized for use with claude code") &&
  356. strings.Contains(m, "cannot be used for other api requests")
  357. }
  358. // sseDataRe matches SSE data lines with optional whitespace after colon.
  359. // Some upstream APIs return non-standard "data:" without space (should be "data: ").
  360. var (
  361. sseDataRe = regexp.MustCompile(`^data:\s*`)
  362. claudeCliUserAgentRe = regexp.MustCompile(`(?i)^claude-cli/\d+\.\d+\.\d+`)
  363. // claudeCodePromptPrefixes 用于检测 Claude Code 系统提示词的前缀列表
  364. // 支持多种变体:标准版、Agent SDK 版、Explore Agent 版、Compact 版等
  365. // 注意:前缀之间不应存在包含关系,否则会导致冗余匹配
  366. claudeCodePromptPrefixes = []string{
  367. "You are Claude Code, Anthropic's official CLI for Claude", // 标准版 & Agent SDK 版(含 running within...)
  368. "You are a Claude agent, built on Anthropic's Claude Agent SDK", // Agent SDK 变体
  369. "You are a file search specialist for Claude Code", // Explore Agent 版
  370. "You are a helpful AI assistant tasked with summarizing conversations", // Compact 版
  371. }
  372. )
  373. // ErrNoAvailableAccounts 表示没有可用的账号
  374. var ErrNoAvailableAccounts = errors.New("no available accounts")
  375. // ErrClaudeCodeOnly 表示分组仅允许 Claude Code 客户端访问
  376. var ErrClaudeCodeOnly = errors.New("this group only allows Claude Code clients")
  377. // allowedHeaders 白名单headers(参考CRS项目)
  378. var allowedHeaders = map[string]bool{
  379. "accept": true,
  380. "x-stainless-retry-count": true,
  381. "x-stainless-timeout": true,
  382. "x-stainless-lang": true,
  383. "x-stainless-package-version": true,
  384. "x-stainless-os": true,
  385. "x-stainless-arch": true,
  386. "x-stainless-runtime": true,
  387. "x-stainless-runtime-version": true,
  388. "x-stainless-helper-method": true,
  389. "anthropic-dangerous-direct-browser-access": true,
  390. "anthropic-version": true,
  391. "x-app": true,
  392. "anthropic-beta": true,
  393. "accept-language": true,
  394. "sec-fetch-mode": true,
  395. "user-agent": true,
  396. "content-type": true,
  397. "accept-encoding": true,
  398. "x-claude-code-session-id": true,
  399. "x-client-request-id": true,
  400. }
  401. // ErrStickySessionNotFound is returned by GatewayCache.GetSessionAccountID
  402. // when no binding exists for the session. It abstracts away the underlying
  403. // cache implementation (e.g. redis.Nil), mirroring ErrRefreshTokenNotFound.
  404. var ErrStickySessionNotFound = errors.New("sticky session not found")
  405. // ErrReasoningContentNotFound is returned by GatewayCache.GetReasoningContent
  406. // when no cached reasoning content exists for the reasoning item ID.
  407. var ErrReasoningContentNotFound = errors.New("reasoning content not found")
  408. // GatewayCache 定义网关服务的缓存操作接口。
  409. // 提供粘性会话(Sticky Session)的存储、查询、刷新和删除功能。
  410. //
  411. // GatewayCache defines cache operations for gateway service.
  412. // Provides sticky session storage, retrieval, refresh and deletion capabilities.
  413. type GatewayCache interface {
  414. // GetSessionAccountID 获取粘性会话绑定的账号 ID;无绑定时返回
  415. // ErrStickySessionNotFound,使 service 层无需依赖具体缓存实现即可
  416. // 区分"未绑定"与真实读取失败。
  417. // Get the account ID bound to a sticky session. Returns
  418. // ErrStickySessionNotFound when no binding exists so service code can
  419. // distinguish a miss from a real read failure without importing the
  420. // cache driver.
  421. GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error)
  422. // SetSessionAccountID 设置粘性会话与账号的绑定关系
  423. // Set the binding between sticky session and account
  424. SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error
  425. // RefreshSessionTTL 刷新粘性会话的过期时间
  426. // Refresh the expiration time of a sticky session
  427. RefreshSessionTTL(ctx context.Context, groupID int64, sessionHash string, ttl time.Duration) error
  428. // DeleteSessionAccountID 删除粘性会话绑定,用于账号不可用时主动清理
  429. // Delete sticky session binding, used to proactively clean up when account becomes unavailable
  430. DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error
  431. // Grok async video billing snapshot (create → status success).
  432. // SetGrokVideoPendingBilling stores create-time model/duration/resolution for status billing.
  433. SetGrokVideoPendingBilling(ctx context.Context, key string, payload []byte, ttl time.Duration) error
  434. // GetGrokVideoPendingBilling returns the create-time billing snapshot; miss → nil, nil.
  435. GetGrokVideoPendingBilling(ctx context.Context, key string) ([]byte, error)
  436. // ClaimGrokVideoBilled atomically marks a video request as billed (SetNX).
  437. // Returns true when this caller won the claim; false when already billed or claim unavailable.
  438. ClaimGrokVideoBilled(ctx context.Context, key string, ttl time.Duration) (bool, error)
  439. // ReleaseGrokVideoBilled clears a claim so a failed RecordUsage can retry billing.
  440. ReleaseGrokVideoBilled(ctx context.Context, key string) error
  441. // Reasoning content cache (Responses→Chat Completions 桥接)。
  442. // SetReasoningContent 按 reasoning item id 缓存 reasoning 全文,供后续请求
  443. // 在客户端不回传明文 summary 时回注 reasoning_content(DeepSeek thinking
  444. // mode 要求回传,否则 400)。
  445. SetReasoningContent(ctx context.Context, itemID string, content string, ttl time.Duration) error
  446. // GetReasoningContent 返回缓存的 reasoning 全文;未命中返回
  447. // ErrReasoningContentNotFound,使 service 层无需依赖具体缓存实现即可
  448. // 区分"未缓存"与真实读取失败。
  449. GetReasoningContent(ctx context.Context, itemID string) (string, error)
  450. }
  451. // derefGroupID safely dereferences *int64 to int64, returning 0 if nil
  452. func derefGroupID(groupID *int64) int64 {
  453. if groupID == nil {
  454. return 0
  455. }
  456. return *groupID
  457. }
  458. func resolveUserGroupRateCacheTTL(cfg *config.Config) time.Duration {
  459. if cfg == nil || cfg.Gateway.UserGroupRateCacheTTLSeconds <= 0 {
  460. return defaultUserGroupRateCacheTTL
  461. }
  462. return time.Duration(cfg.Gateway.UserGroupRateCacheTTLSeconds) * time.Second
  463. }
  464. func resolveModelsListCacheTTL(cfg *config.Config) time.Duration {
  465. if cfg == nil || cfg.Gateway.ModelsListCacheTTLSeconds <= 0 {
  466. return defaultModelsListCacheTTL
  467. }
  468. return time.Duration(cfg.Gateway.ModelsListCacheTTLSeconds) * time.Second
  469. }
  470. func modelsListCacheKey(groupID *int64, platform string) string {
  471. return fmt.Sprintf("%d|%s", derefGroupID(groupID), strings.TrimSpace(platform))
  472. }
  473. func compositeModelOwnershipCacheKey(groupID int64, model string) string {
  474. return fmt.Sprintf("%s%d|%s", compositeModelOwnershipCachePrefix, groupID, strings.TrimSpace(model))
  475. }
  476. func prefetchedStickyGroupIDFromContext(ctx context.Context) (int64, bool) {
  477. return PrefetchedStickyGroupIDFromContext(ctx)
  478. }
  479. func prefetchedStickyAccountIDFromContext(ctx context.Context, groupID *int64) int64 {
  480. prefetchedGroupID, ok := prefetchedStickyGroupIDFromContext(ctx)
  481. if !ok || prefetchedGroupID != derefGroupID(groupID) {
  482. return 0
  483. }
  484. if accountID, ok := PrefetchedStickyAccountIDFromContext(ctx); ok && accountID > 0 {
  485. return accountID
  486. }
  487. return 0
  488. }
  489. // shouldClearStickySession 检查账号是否处于不可调度状态,需要清理粘性会话绑定。
  490. // 委托 IsSchedulable() 判断账号级可调度性(状态、配额、过载、限流等),
  491. // 额外检查模型级限流。
  492. //
  493. // shouldClearStickySession checks if an account is in an unschedulable state
  494. // and the sticky session binding should be cleared.
  495. // Delegates to IsSchedulable() for account-level checks, plus model-level rate limiting.
  496. func shouldClearStickySession(account *Account, requestedModel string) bool {
  497. if account == nil {
  498. return false
  499. }
  500. if !account.IsSchedulable() {
  501. return true
  502. }
  503. if remaining := account.GetRateLimitRemainingTimeWithContext(context.Background(), requestedModel); remaining > 0 {
  504. return true
  505. }
  506. return false
  507. }
  508. type AccountWaitPlan struct {
  509. AccountID int64
  510. MaxConcurrency int
  511. Timeout time.Duration
  512. MaxWaiting int
  513. }
  514. type AccountSelectionResult struct {
  515. Account *Account
  516. Acquired bool
  517. ReleaseFunc func()
  518. WaitPlan *AccountWaitPlan // nil means no wait allowed
  519. // profitGate 携带本次选号真实生效的利润门(无门为 nil)。门安装在调度栈的
  520. // 局部 ctx 上,handler 必须经 ContextWithSelectionProfitGate 重放后才能在
  521. // 调度栈之外做抢槽后终检与准入后粘性绑定。
  522. profitGate *openAIProfitControlGate
  523. }
  524. // ProfitGateActive 报告本次选号是否处于利润门之下。
  525. func (r *AccountSelectionResult) ProfitGateActive() bool {
  526. return r != nil && r.profitGate != nil
  527. }
  528. // ClaudeUsage 表示Claude API返回的usage信息
  529. type ClaudeUsage struct {
  530. InputTokens int `json:"input_tokens"`
  531. OutputTokens int `json:"output_tokens"`
  532. CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
  533. CacheReadInputTokens int `json:"cache_read_input_tokens"`
  534. CacheCreation5mTokens int // 5分钟缓存创建token(来自嵌套 cache_creation 对象)
  535. CacheCreation1hTokens int // 1小时缓存创建token(来自嵌套 cache_creation 对象)
  536. ImageOutputTokens int `json:"image_output_tokens,omitempty"`
  537. }
  538. // ForwardResult 转发结果
  539. type AudioUsage struct {
  540. Mode string // realtime | tts | stt
  541. DurationOrUnits float64 // minutes / million-chars / hours
  542. }
  543. type ForwardResult struct {
  544. RequestID string
  545. // UpstreamHeaders 是直接上游的响应头,用于按账户配置解析上游请求标识。
  546. UpstreamHeaders http.Header
  547. Usage ClaudeUsage
  548. Model string
  549. // UpstreamModel is the actual upstream model after mapping.
  550. // Prefer empty when it is identical to Model; persistence normalizes equal values away as no-op mappings.
  551. UpstreamModel string
  552. // UpstreamResponseModel is captured from the raw successful upstream
  553. // response before any client-facing rewrite or protocol conversion.
  554. UpstreamResponseModel string
  555. UpstreamResponseModelConflict bool
  556. // UpstreamResponseServiceTier is the tier the upstream reports having used
  557. // (Anthropic usage.speed: "fast" / "standard"); "" when not declared.
  558. UpstreamResponseServiceTier string
  559. Stream bool
  560. Duration time.Duration
  561. FirstTokenMs *int // 首字时间(流式请求)
  562. ClientDisconnect bool // 客户端是否在流式传输过程中断开
  563. ReasoningEffort *string
  564. // RequestedReasoningEffort is the client-requested effort before mapping.
  565. RequestedReasoningEffort *string
  566. // ServiceTier records the tier requested by the client. OpenAI uses
  567. // service_tier; Anthropic speed=fast is normalized to "fast". Usage recording
  568. // lowers it to UpstreamResponseServiceTier when the upstream reports a
  569. // cheaper tier (see ResolveBillingServiceTier).
  570. ServiceTier *string
  571. // 图片生成计费字段(图片生成模型使用)
  572. ImageCount int // 生成的图片数量
  573. ImageSize string // 最终计费尺寸 "1K", "2K", "4K"
  574. ImageInputSize string // 请求中的原始图片尺寸
  575. ImageOutputSize string // 上游响应中的图片尺寸
  576. ImageOutputSizes []string
  577. ImageSizeSource string
  578. ImageSizeBreakdown map[string]int
  579. SearchCount int
  580. AudioUsage *AudioUsage
  581. }
  582. // GatewayFailureStage identifies which request stage failed. The zero value is
  583. // intentionally treated as inference so existing UpstreamFailoverError callers
  584. // retain their current behavior.
  585. type GatewayFailureStage string
  586. const (
  587. GatewayFailureStageInference GatewayFailureStage = "inference"
  588. GatewayFailureStageAccountAuth GatewayFailureStage = "account_auth"
  589. )
  590. // GatewayFailureScope identifies whether selecting another account can help.
  591. type GatewayFailureScope string
  592. const (
  593. GatewayFailureScopeAccount GatewayFailureScope = "account"
  594. GatewayFailureScopeProvider GatewayFailureScope = "provider"
  595. GatewayFailureScopeRequest GatewayFailureScope = "request"
  596. )
  597. // NextAccountAction is tri-state for backwards compatibility. The zero value
  598. // means legacy retry behavior; only NextAccountStop explicitly short-circuits.
  599. type NextAccountAction uint8
  600. const (
  601. NextAccountLegacyRetry NextAccountAction = iota
  602. NextAccountRetry
  603. NextAccountStop
  604. )
  605. type GatewayFailureReason string
  606. // UpstreamFailoverError indicates an upstream or credential error that may
  607. // trigger account failover. Additive metadata keeps existing composite literals
  608. // source-compatible and preserves their legacy retry-next-account behavior.
  609. type UpstreamFailoverError struct {
  610. StatusCode int
  611. ResponseBody []byte // 上游响应体,用于错误透传规则匹配
  612. ResponseHeaders http.Header // 上游响应头,用于透传 cf-ray/cf-mitigated/content-type 等诊断信息
  613. ForceCacheBilling bool // Antigravity 粘性会话切换时设为 true
  614. RetryableOnSameAccount bool // 临时性错误(如 Google 间歇性 400、空响应),应在同一账号上重试 N 次再切换
  615. SameAccountRetryDelay time.Duration // 同账号重试的最小间隔;零值使用 handler 默认值
  616. SameAccountRetryDeadline time.Time // 同账号重试截止时间;零值表示仅受 retryLimit 限制
  617. SameAccountRetryMax int // 可选的错误级同账号重试上限,低于 handler 默认预算时优先采用
  618. RequestScopedTransient bool // 故障因素与账号无关(如上游按客户端身份/模型容量降载):可同账号重试,但不得据此对账号做临时封禁
  619. SafeToFailoverAfterWrite bool // 仅写出 SSE 注释等非语义字节时,仍可在同一客户端流中切换账号
  620. Stage GatewayFailureStage
  621. Scope GatewayFailureScope
  622. Reason GatewayFailureReason
  623. NextAccountAction NextAccountAction
  624. ClientStatusCode int
  625. ClientMessage string
  626. }
  627. func (e *UpstreamFailoverError) Error() string {
  628. if e != nil && e.Stage == GatewayFailureStageAccountAuth {
  629. return fmt.Sprintf("credential failure: %s (failover)", e.Reason)
  630. }
  631. return fmt.Sprintf("upstream error: %d (failover)", e.StatusCode)
  632. }
  633. func (e *UpstreamFailoverError) ShouldRetryNextAccount() bool {
  634. return e != nil && e.NextAccountAction != NextAccountStop
  635. }
  636. func (e *UpstreamFailoverError) IsCredentialFailure() bool {
  637. return e != nil && e.Stage == GatewayFailureStageAccountAuth
  638. }
  639. // ShouldReportAccountScheduleFailure prevents provider- and request-scoped
  640. // credential failures from being misattributed to the selected account. Legacy
  641. // and inference failures retain their existing scheduler-health behavior.
  642. func (e *UpstreamFailoverError) ShouldReportAccountScheduleFailure() bool {
  643. if e == nil || e.Reason == "account_traffic_limit" {
  644. return false
  645. }
  646. return !e.IsCredentialFailure() || e.Scope == GatewayFailureScopeAccount
  647. }
  648. // sseStreamErrorEventError 表示上游 SSE 流体内出现 event:error 帧。
  649. // RawData 是该事件 data: 行的原始 JSON 字符串
  650. // (Anthropic 标准结构 {"type":"error","error":{"type":"...","message":"..."}})。
  651. // Error() 保持原字符串以兼容现有日志/检索;调用方应通过 errors.As
  652. // 提取 RawData 并构造 UpstreamFailoverError.ResponseBody。
  653. type sseStreamErrorEventError struct {
  654. RawData string
  655. }
  656. func (e *sseStreamErrorEventError) Error() string { return "have error in stream" }
  657. // TempUnscheduleRetryableError 对 RetryableOnSameAccount 类型的 failover 错误触发临时封禁。
  658. // 由 handler 层在同账号重试全部用尽、切换账号时调用。
  659. func (s *GatewayService) TempUnscheduleRetryableError(ctx context.Context, accountID int64, failoverErr *UpstreamFailoverError) {
  660. if failoverErr == nil || !failoverErr.RetryableOnSameAccount {
  661. return
  662. }
  663. // 请求级瞬时故障与账号健康无关:封禁只会把与故障无关的账号一并摘掉,
  664. // 而故障因素(客户端身份、模型容量)在下一个账号上完全相同。
  665. if failoverErr.RequestScopedTransient {
  666. return
  667. }
  668. // 根据状态码选择封禁策略
  669. switch failoverErr.StatusCode {
  670. case http.StatusBadRequest:
  671. tempUnscheduleGoogleConfigError(ctx, s.accountRepo, accountID, "[handler]")
  672. case http.StatusBadGateway:
  673. tempUnscheduleEmptyResponse(ctx, s.accountRepo, accountID, "[handler]")
  674. }
  675. }
  676. // GatewayService handles API gateway operations
  677. type GatewayService struct {
  678. accountRepo AccountRepository
  679. groupRepo GroupRepository
  680. usageLogRepo UsageLogRepository
  681. usageBillingRepo UsageBillingRepository
  682. userRepo UserRepository
  683. userSubRepo UserSubscriptionRepository
  684. userGroupRateRepo UserGroupRateRepository
  685. cache GatewayCache
  686. digestStore *DigestSessionStore
  687. cfg *config.Config
  688. schedulerSnapshot *SchedulerSnapshotService
  689. billingService *BillingService
  690. rateLimitService *RateLimitService
  691. billingCacheService *BillingCacheService
  692. identityService *IdentityService
  693. httpUpstream HTTPUpstream
  694. deferredService *DeferredService
  695. concurrencyService *ConcurrencyService
  696. claudeTokenProvider *ClaudeTokenProvider
  697. sessionLimitCache SessionLimitCache // 会话数量限制缓存(仅 Anthropic OAuth/SetupToken)
  698. rpmCache RPMCache // RPM 计数缓存(仅 Anthropic OAuth/SetupToken)
  699. userGroupRateResolver *userGroupRateResolver
  700. userGroupRateCache *gocache.Cache
  701. userGroupRateSF singleflight.Group
  702. modelsListCache *gocache.Cache
  703. modelsListCacheTTL time.Duration
  704. settingService *SettingService
  705. responseHeaderFilter *responseheaders.CompiledHeaderFilter
  706. debugModelRouting atomic.Bool
  707. debugClaudeMimic atomic.Bool
  708. channelService *ChannelService
  709. resolver *ModelPricingResolver
  710. compositeResolver *CompositeRouteResolver
  711. debugGatewayBodyFile atomic.Pointer[os.File] // non-nil when SUB2API_DEBUG_GATEWAY_BODY is set
  712. tlsFPProfileService *TLSFingerprintProfileService
  713. balanceNotifyService *BalanceNotifyService
  714. userPlatformQuotaRepo UserPlatformQuotaRepository
  715. }
  716. // NewGatewayService creates a new GatewayService
  717. func NewGatewayService(
  718. accountRepo AccountRepository,
  719. groupRepo GroupRepository,
  720. usageLogRepo UsageLogRepository,
  721. usageBillingRepo UsageBillingRepository,
  722. userRepo UserRepository,
  723. userSubRepo UserSubscriptionRepository,
  724. userGroupRateRepo UserGroupRateRepository,
  725. cache GatewayCache,
  726. cfg *config.Config,
  727. schedulerSnapshot *SchedulerSnapshotService,
  728. concurrencyService *ConcurrencyService,
  729. billingService *BillingService,
  730. rateLimitService *RateLimitService,
  731. billingCacheService *BillingCacheService,
  732. identityService *IdentityService,
  733. httpUpstream HTTPUpstream,
  734. deferredService *DeferredService,
  735. claudeTokenProvider *ClaudeTokenProvider,
  736. sessionLimitCache SessionLimitCache,
  737. rpmCache RPMCache,
  738. digestStore *DigestSessionStore,
  739. settingService *SettingService,
  740. tlsFPProfileService *TLSFingerprintProfileService,
  741. channelService *ChannelService,
  742. resolver *ModelPricingResolver,
  743. compositeResolver *CompositeRouteResolver,
  744. balanceNotifyService *BalanceNotifyService,
  745. userPlatformQuotaRepo UserPlatformQuotaRepository,
  746. ) *GatewayService {
  747. userGroupRateTTL := resolveUserGroupRateCacheTTL(cfg)
  748. modelsListTTL := resolveModelsListCacheTTL(cfg)
  749. svc := &GatewayService{
  750. accountRepo: accountRepo,
  751. groupRepo: groupRepo,
  752. usageLogRepo: usageLogRepo,
  753. usageBillingRepo: usageBillingRepo,
  754. userRepo: userRepo,
  755. userSubRepo: userSubRepo,
  756. userGroupRateRepo: userGroupRateRepo,
  757. cache: cache,
  758. digestStore: digestStore,
  759. cfg: cfg,
  760. schedulerSnapshot: schedulerSnapshot,
  761. concurrencyService: concurrencyService,
  762. billingService: billingService,
  763. rateLimitService: rateLimitService,
  764. billingCacheService: billingCacheService,
  765. identityService: identityService,
  766. httpUpstream: httpUpstream,
  767. deferredService: deferredService,
  768. claudeTokenProvider: claudeTokenProvider,
  769. sessionLimitCache: sessionLimitCache,
  770. rpmCache: rpmCache,
  771. userGroupRateCache: gocache.New(userGroupRateTTL, time.Minute),
  772. settingService: settingService,
  773. modelsListCache: gocache.New(modelsListTTL, time.Minute),
  774. modelsListCacheTTL: modelsListTTL,
  775. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  776. tlsFPProfileService: tlsFPProfileService,
  777. channelService: channelService,
  778. resolver: resolver,
  779. compositeResolver: compositeResolver,
  780. balanceNotifyService: balanceNotifyService,
  781. userPlatformQuotaRepo: userPlatformQuotaRepo,
  782. }
  783. if compositeResolver != nil {
  784. compositeResolver.SetModelOwnershipResolver(svc.resolveCompositeModelOwnership)
  785. }
  786. svc.userGroupRateResolver = newUserGroupRateResolver(
  787. userGroupRateRepo,
  788. svc.userGroupRateCache,
  789. userGroupRateTTL,
  790. &svc.userGroupRateSF,
  791. "service.gateway",
  792. )
  793. svc.debugModelRouting.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_MODEL_ROUTING")))
  794. svc.debugClaudeMimic.Store(parseDebugEnvBool(os.Getenv("SUB2API_DEBUG_CLAUDE_MIMIC")))
  795. if path := strings.TrimSpace(os.Getenv(debugGatewayBodyEnv)); path != "" {
  796. svc.initDebugGatewayBodyFile(path)
  797. }
  798. return svc
  799. }
  800. // GenerateSessionHash 从预解析请求计算粘性会话 hash
  801. func (s *GatewayService) GenerateSessionHash(parsed *ParsedRequest) string {
  802. if parsed == nil {
  803. return ""
  804. }
  805. // 1. 最高优先级:从 metadata.user_id 提取 session_xxx
  806. if parsed.MetadataUserID != "" {
  807. uid := ParseMetadataUserID(parsed.MetadataUserID)
  808. if uid != nil && uid.SessionID != "" {
  809. slog.Info("sticky.hash_source",
  810. "source", "metadata_user_id",
  811. "session_id", uid.SessionID,
  812. "device_id", uid.DeviceID,
  813. "is_new_format", uid.IsNewFormat,
  814. )
  815. return uid.SessionID
  816. }
  817. slog.Info("sticky.hash_metadata_parse_failed",
  818. "metadata_user_id", parsed.MetadataUserID,
  819. "parsed_nil", uid == nil,
  820. )
  821. }
  822. // 2. 提取带 cache_control: {type: "ephemeral"} 的内容
  823. cacheableContent := s.extractCacheableContent(parsed)
  824. if cacheableContent != "" {
  825. hash := s.hashContent(cacheableContent)
  826. slog.Info("sticky.hash_source",
  827. "source", "cacheable_content",
  828. "hash", hash,
  829. )
  830. return hash
  831. }
  832. // 3. 最后 fallback: 使用 session上下文 + system + 所有消息的完整摘要串
  833. var combined strings.Builder
  834. // 混入请求上下文区分因子,避免不同用户相同消息产生相同 hash
  835. if parsed.SessionContext != nil {
  836. _, _ = combined.WriteString(parsed.SessionContext.ClientIP)
  837. _, _ = combined.WriteString(":")
  838. _, _ = combined.WriteString(NormalizeSessionUserAgent(parsed.SessionContext.UserAgent))
  839. _, _ = combined.WriteString(":")
  840. _, _ = combined.WriteString(strconv.FormatInt(parsed.SessionContext.APIKeyID, 10))
  841. _, _ = combined.WriteString("|")
  842. }
  843. if systemText := extractTextFromSystemRaw(parsed.SystemRaw()); systemText != "" {
  844. _, _ = combined.WriteString(systemText)
  845. }
  846. contentStart := combined.Len()
  847. appendMessageTextsFromRaw(&combined, parsed.MessagesRaw())
  848. if combined.Len() == contentStart {
  849. appendResponsesSessionAnchorFromRaw(&combined, parsed.InputRaw())
  850. }
  851. if combined.Len() > 0 {
  852. hash := s.hashContent(combined.String())
  853. slog.Info("sticky.hash_source",
  854. "source", "message_content_fallback",
  855. "hash", hash,
  856. "content_len", combined.Len(),
  857. )
  858. return hash
  859. }
  860. return ""
  861. }
  862. // BindStickySession sets session -> account binding with standard TTL.
  863. func (s *GatewayService) BindStickySession(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error {
  864. if sessionHash == "" || accountID <= 0 || s.cache == nil {
  865. return nil
  866. }
  867. return s.cache.SetSessionAccountID(ctx, derefGroupID(groupID), sessionHash, accountID, stickySessionTTL)
  868. }
  869. // bindGatewayStickySessionDuringSelection preserves the normal eager sticky
  870. // behavior unless a profit gate is installed. Profit-controlled requests bind
  871. // only after the terminal post-slot check, otherwise a rejected candidate could
  872. // overwrite a healthy pre-existing sticky binding.
  873. func (s *GatewayService) bindGatewayStickySessionDuringSelection(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error {
  874. if gatewayProfitControlGateActive(ctx) {
  875. return nil
  876. }
  877. return s.BindStickySession(ctx, groupID, sessionHash, accountID)
  878. }
  879. // BindStickySessionAfterProfitAdmission records a terminally admitted
  880. // account. Without a profit gate it preserves the pre-existing eager binding
  881. // behavior at the handler bind points. With a gate it never replaces a
  882. // different binding that already exists: a temporarily ineligible sticky
  883. // account remains bound and automatically becomes eligible again if its
  884. // account rate recovers.
  885. func (s *GatewayService) BindStickySessionAfterProfitAdmission(ctx context.Context, groupID *int64, sessionHash string, accountID int64) error {
  886. if sessionHash == "" || accountID <= 0 || s.cache == nil {
  887. return nil
  888. }
  889. if !gatewayProfitControlGateActive(ctx) {
  890. return s.BindStickySession(ctx, groupID, sessionHash, accountID)
  891. }
  892. existingAccountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
  893. if err != nil && !errors.Is(err, ErrStickySessionNotFound) {
  894. // 读失败时无法判断既有绑定,保守跳过而不是冒着覆盖健康绑定的风险写入。
  895. slog.Warn("profit_control_sticky_binding_read_failed", "group_id", derefGroupID(groupID), "account_id", accountID, "error", err)
  896. return nil
  897. }
  898. if existingAccountID > 0 && existingAccountID != accountID {
  899. return nil
  900. }
  901. return s.BindStickySession(ctx, groupID, sessionHash, accountID)
  902. }
  903. // GetCachedSessionAccountID retrieves the account ID bound to a sticky session.
  904. // Returns 0 if no binding exists or on error.
  905. func (s *GatewayService) GetCachedSessionAccountID(ctx context.Context, groupID *int64, sessionHash string) (int64, error) {
  906. if sessionHash == "" || s.cache == nil {
  907. return 0, nil
  908. }
  909. accountID, err := s.cache.GetSessionAccountID(ctx, derefGroupID(groupID), sessionHash)
  910. if err != nil {
  911. return 0, err
  912. }
  913. return accountID, nil
  914. }
  915. // FindGeminiSession 查找 Gemini 会话(基于内容摘要链的 Fallback 匹配)
  916. // 返回最长匹配的会话信息(uuid, accountID)
  917. func (s *GatewayService) FindGeminiSession(_ context.Context, groupID int64, prefixHash, digestChain string) (uuid string, accountID int64, matchedChain string, found bool) {
  918. if digestChain == "" || s.digestStore == nil {
  919. return "", 0, "", false
  920. }
  921. return s.digestStore.Find(groupID, prefixHash, digestChain)
  922. }
  923. // SaveGeminiSession 保存 Gemini 会话。oldDigestChain 为 Find 返回的 matchedChain,用于删旧 key。
  924. func (s *GatewayService) SaveGeminiSession(_ context.Context, groupID int64, prefixHash, digestChain, uuid string, accountID int64, oldDigestChain string) error {
  925. if digestChain == "" || s.digestStore == nil {
  926. return nil
  927. }
  928. s.digestStore.Save(groupID, prefixHash, digestChain, uuid, accountID, oldDigestChain)
  929. return nil
  930. }
  931. // FindAnthropicSession 查找 Anthropic 会话(基于内容摘要链的 Fallback 匹配)
  932. func (s *GatewayService) FindAnthropicSession(_ context.Context, groupID int64, prefixHash, digestChain string) (uuid string, accountID int64, matchedChain string, found bool) {
  933. if digestChain == "" || s.digestStore == nil {
  934. return "", 0, "", false
  935. }
  936. return s.digestStore.Find(groupID, prefixHash, digestChain)
  937. }
  938. // SaveAnthropicSession 保存 Anthropic 会话
  939. func (s *GatewayService) SaveAnthropicSession(_ context.Context, groupID int64, prefixHash, digestChain, uuid string, accountID int64, oldDigestChain string) error {
  940. if digestChain == "" || s.digestStore == nil {
  941. return nil
  942. }
  943. s.digestStore.Save(groupID, prefixHash, digestChain, uuid, accountID, oldDigestChain)
  944. return nil
  945. }
  946. func (s *GatewayService) extractCacheableContent(parsed *ParsedRequest) string {
  947. if parsed == nil {
  948. return ""
  949. }
  950. systemText := extractCacheableTextFromSystemRaw(parsed.SystemRaw())
  951. if messageText := extractCacheableTextFromMessagesRaw(parsed.MessagesRaw()); messageText != "" {
  952. return messageText
  953. }
  954. return systemText
  955. }
  956. func parseRawJSONView(raw []byte) gjson.Result {
  957. if len(raw) == 0 {
  958. return gjson.Result{}
  959. }
  960. // 这里只做同步只读解析,避免 gjson.ParseBytes 为大 messages/contents 复制整段 raw。
  961. return gjson.Parse(*(*string)(unsafe.Pointer(&raw)))
  962. }
  963. func extractTextFromSystemRaw(raw []byte) string {
  964. system := parseRawJSONView(raw)
  965. switch system.Type {
  966. case gjson.String:
  967. return system.String()
  968. case gjson.JSON:
  969. if !system.IsArray() {
  970. return ""
  971. }
  972. var builder strings.Builder
  973. system.ForEach(func(_, part gjson.Result) bool {
  974. if text := part.Get("text").String(); text != "" {
  975. _, _ = builder.WriteString(text)
  976. }
  977. return true
  978. })
  979. return builder.String()
  980. }
  981. return ""
  982. }
  983. func extractTextFromContentRaw(content gjson.Result) string {
  984. switch content.Type {
  985. case gjson.String:
  986. return content.String()
  987. case gjson.JSON:
  988. if !content.IsArray() {
  989. return ""
  990. }
  991. var builder strings.Builder
  992. content.ForEach(func(_, part gjson.Result) bool {
  993. if part.Get("type").String() == "text" {
  994. if text := part.Get("text").String(); text != "" {
  995. _, _ = builder.WriteString(text)
  996. }
  997. }
  998. return true
  999. })
  1000. return builder.String()
  1001. }
  1002. return ""
  1003. }
  1004. func appendMessageTextsFromRaw(builder *strings.Builder, raw []byte) {
  1005. if builder == nil || len(raw) == 0 {
  1006. return
  1007. }
  1008. messages := parseRawJSONView(raw)
  1009. if !messages.IsArray() {
  1010. return
  1011. }
  1012. messages.ForEach(func(_, msg gjson.Result) bool {
  1013. if content := msg.Get("content"); content.Exists() {
  1014. _, _ = builder.WriteString(extractTextFromContentRaw(content))
  1015. return true
  1016. }
  1017. parts := msg.Get("parts")
  1018. if parts.IsArray() {
  1019. parts.ForEach(func(_, part gjson.Result) bool {
  1020. if text := part.Get("text").String(); text != "" {
  1021. _, _ = builder.WriteString(text)
  1022. }
  1023. return true
  1024. })
  1025. }
  1026. return true
  1027. })
  1028. }
  1029. func appendResponsesSessionAnchorFromRaw(builder *strings.Builder, raw []byte) {
  1030. if builder == nil || len(raw) == 0 {
  1031. return
  1032. }
  1033. input := parseRawJSONView(raw)
  1034. if input.Type == gjson.String {
  1035. _, _ = builder.WriteString(input.String())
  1036. return
  1037. }
  1038. if !input.IsArray() {
  1039. return
  1040. }
  1041. input.ForEach(func(_, item gjson.Result) bool {
  1042. if item.Type == gjson.String {
  1043. _, _ = builder.WriteString(item.String())
  1044. return false
  1045. }
  1046. switch item.Get("role").String() {
  1047. case "system", "developer":
  1048. appendResponsesContentText(builder, item.Get("content"))
  1049. case "user":
  1050. appendResponsesContentText(builder, item.Get("content"))
  1051. return false
  1052. default:
  1053. if item.Get("type").String() == "input_text" {
  1054. if text := item.Get("text").String(); text != "" {
  1055. _, _ = builder.WriteString(text)
  1056. }
  1057. return false
  1058. }
  1059. }
  1060. return true
  1061. })
  1062. }
  1063. func appendResponsesContentText(builder *strings.Builder, content gjson.Result) {
  1064. if builder == nil || !content.Exists() {
  1065. return
  1066. }
  1067. if content.Type == gjson.String {
  1068. _, _ = builder.WriteString(content.String())
  1069. return
  1070. }
  1071. if !content.IsArray() {
  1072. return
  1073. }
  1074. content.ForEach(func(_, part gjson.Result) bool {
  1075. switch part.Get("type").String() {
  1076. case "input_text", "text":
  1077. if text := part.Get("text").String(); text != "" {
  1078. _, _ = builder.WriteString(text)
  1079. }
  1080. }
  1081. return true
  1082. })
  1083. }
  1084. func extractCacheableTextFromSystemRaw(raw []byte) string {
  1085. system := parseRawJSONView(raw)
  1086. if !system.IsArray() {
  1087. return ""
  1088. }
  1089. var builder strings.Builder
  1090. system.ForEach(func(_, part gjson.Result) bool {
  1091. if part.Get("cache_control.type").String() == "ephemeral" {
  1092. if text := part.Get("text").String(); text != "" {
  1093. _, _ = builder.WriteString(text)
  1094. }
  1095. }
  1096. return true
  1097. })
  1098. return builder.String()
  1099. }
  1100. func extractCacheableTextFromMessagesRaw(raw []byte) string {
  1101. messages := parseRawJSONView(raw)
  1102. if !messages.IsArray() {
  1103. return ""
  1104. }
  1105. var text string
  1106. messages.ForEach(func(_, msg gjson.Result) bool {
  1107. content := msg.Get("content")
  1108. if !content.IsArray() {
  1109. return true
  1110. }
  1111. found := false
  1112. content.ForEach(func(_, part gjson.Result) bool {
  1113. if part.Get("cache_control.type").String() == "ephemeral" {
  1114. found = true
  1115. return false
  1116. }
  1117. return true
  1118. })
  1119. if found {
  1120. text = extractTextFromContentRaw(content)
  1121. return false
  1122. }
  1123. return true
  1124. })
  1125. return text
  1126. }
  1127. func (s *GatewayService) hashContent(content string) string {
  1128. h := xxhash.Sum64String(content)
  1129. return strconv.FormatUint(h, 36)
  1130. }
  1131. // GetAccessToken 获取账号凭证
  1132. func (s *GatewayService) GetAccessToken(ctx context.Context, account *Account) (string, string, error) {
  1133. switch account.Type {
  1134. case AccountTypeOAuth, AccountTypeSetupToken:
  1135. // Both oauth and setup-token use OAuth token flow
  1136. return s.getOAuthToken(ctx, account)
  1137. case AccountTypeAPIKey:
  1138. apiKey := account.GetCredential("api_key")
  1139. if apiKey == "" {
  1140. return "", "", errors.New("api_key not found in credentials")
  1141. }
  1142. return apiKey, "apikey", nil
  1143. case AccountTypeBedrock:
  1144. return "", "bedrock", nil // Bedrock 使用 SigV4 签名或 API Key,由 forwardBedrock 处理
  1145. case AccountTypeServiceAccount:
  1146. if account.Platform != PlatformAnthropic {
  1147. return "", "", fmt.Errorf("unsupported service account platform: %s", account.Platform)
  1148. }
  1149. if s.claudeTokenProvider == nil {
  1150. return "", "", errors.New("claude token provider not configured")
  1151. }
  1152. accessToken, err := s.claudeTokenProvider.GetAccessToken(ctx, account)
  1153. if err != nil {
  1154. return "", "", err
  1155. }
  1156. return accessToken, "service_account", nil
  1157. default:
  1158. return "", "", fmt.Errorf("unsupported account type: %s", account.Type)
  1159. }
  1160. }
  1161. func (s *GatewayService) getOAuthToken(ctx context.Context, account *Account) (string, string, error) {
  1162. // 对于 Anthropic OAuth 账号,使用 ClaudeTokenProvider 获取缓存的 token
  1163. if account.Platform == PlatformAnthropic && account.Type == AccountTypeOAuth && s.claudeTokenProvider != nil {
  1164. accessToken, err := s.claudeTokenProvider.GetAccessToken(ctx, account)
  1165. if err != nil {
  1166. return "", "", err
  1167. }
  1168. return accessToken, "oauth", nil
  1169. }
  1170. // Grok OAuth: prefer access_token from credentials (background refresher keeps it warm).
  1171. if account.Platform == PlatformGrok && account.Type == AccountTypeOAuth {
  1172. accessToken := account.GetGrokAccessToken()
  1173. if accessToken == "" {
  1174. return "", "", errors.New("grok access_token not found in credentials")
  1175. }
  1176. return accessToken, "oauth", nil
  1177. }
  1178. // 其他情况(Gemini 有自己的 TokenProvider,setup-token 类型等)直接从账号读取
  1179. accessToken := account.GetCredential("access_token")
  1180. if accessToken == "" {
  1181. return "", "", errors.New("access_token not found in credentials")
  1182. }
  1183. // Token刷新由后台 TokenRefreshService 处理,此处只返回当前token
  1184. return accessToken, "oauth", nil
  1185. }
  1186. // GetAvailableModels returns the list of models available for a group
  1187. // It aggregates model_mapping keys from all schedulable accounts in the group
  1188. // DoGrokNativeResponsesJSON POSTs a non-streaming Responses body to the account's
  1189. // Grok upstream and returns the raw JSON body. Used by /v1/web_search.
  1190. // Gin-free: UA is always the pinned Grok CLI identity (resolveGrokUpstreamUserAgent ignores inbound).
  1191. func (s *GatewayService) DoGrokNativeResponsesJSON(ctx context.Context, account *Account, body []byte) ([]byte, error) {
  1192. if s == nil || s.httpUpstream == nil {
  1193. return nil, errors.New("http upstream not configured")
  1194. }
  1195. if account == nil {
  1196. return nil, errors.New("account is required")
  1197. }
  1198. if !account.IsGrok() {
  1199. return nil, errors.New("grok account required")
  1200. }
  1201. token, _, err := s.GetAccessToken(ctx, account)
  1202. if err != nil {
  1203. // Credential/token failures should try the next Grok account in the pool.
  1204. return nil, &UpstreamFailoverError{
  1205. StatusCode: http.StatusUnauthorized,
  1206. Reason: GatewayFailureReason("grok_search_token"),
  1207. }
  1208. }
  1209. targetURL, err := buildGrokResponsesURL(account, nil, s.settingService)
  1210. if err != nil {
  1211. return nil, err
  1212. }
  1213. if json.Valid(body) {
  1214. if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model == "" {
  1215. if patched, patchErr := sjson.SetBytes(body, "model", xai.DefaultTextModel); patchErr == nil {
  1216. body = patched
  1217. }
  1218. }
  1219. }
  1220. upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
  1221. if err != nil {
  1222. return nil, fmt.Errorf("build grok responses request: %w", err)
  1223. }
  1224. upstreamReq.Header.Set("Authorization", "Bearer "+token)
  1225. upstreamReq.Header.Set("Content-Type", "application/json")
  1226. upstreamReq.Header.Set("Accept", "application/json")
  1227. upstreamReq.Header.Set("User-Agent", defaultGrokUpstreamUserAgent())
  1228. applyGrokCLIHeaders(upstreamReq.Header)
  1229. account.ApplyHeaderOverrides(upstreamReq.Header)
  1230. proxyURL := ""
  1231. if account.ProxyID != nil && account.Proxy != nil {
  1232. proxyURL = account.Proxy.URL()
  1233. }
  1234. resp, err := s.httpUpstream.Do(WithAccountTrafficRequest(upstreamReq, account), proxyURL, account.ID, account.Concurrency)
  1235. if err != nil {
  1236. if local := AccountTrafficFailover(err); local != nil {
  1237. return nil, local
  1238. }
  1239. return nil, &UpstreamFailoverError{StatusCode: http.StatusBadGateway, Reason: GatewayFailureReason("grok_search_transport")}
  1240. }
  1241. defer func() { _ = resp.Body.Close() }()
  1242. respBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
  1243. if readErr != nil {
  1244. return nil, &UpstreamFailoverError{
  1245. StatusCode: http.StatusBadGateway,
  1246. Reason: GatewayFailureReason("grok_search_read"),
  1247. }
  1248. }
  1249. if resp.StatusCode >= 400 {
  1250. msg := string(respBytes)
  1251. if len(msg) > 200 {
  1252. msg = msg[:200]
  1253. }
  1254. if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusPaymentRequired || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
  1255. return nil, &UpstreamFailoverError{StatusCode: resp.StatusCode, ResponseBody: respBytes}
  1256. }
  1257. return nil, fmt.Errorf("grok upstream %d: %s", resp.StatusCode, msg)
  1258. }
  1259. return respBytes, nil
  1260. }
  1261. func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64, platform string) []string {
  1262. cacheKey := modelsListCacheKey(groupID, platform)
  1263. if s.modelsListCache != nil {
  1264. if cached, found := s.modelsListCache.Get(cacheKey); found {
  1265. if models, ok := cached.([]string); ok {
  1266. modelsListCacheHitTotal.Add(1)
  1267. return cloneStringSlice(models)
  1268. }
  1269. }
  1270. }
  1271. modelsListCacheMissTotal.Add(1)
  1272. var accounts []Account
  1273. var err error
  1274. if groupID != nil {
  1275. accounts, err = s.accountRepo.ListSchedulableByGroupID(ctx, *groupID)
  1276. } else {
  1277. accounts, err = s.accountRepo.ListSchedulable(ctx)
  1278. }
  1279. if err != nil || len(accounts) == 0 {
  1280. return nil
  1281. }
  1282. // Filter by platform if specified
  1283. if platform != "" {
  1284. filtered := make([]Account, 0)
  1285. for _, acc := range accounts {
  1286. if acc.Platform == platform {
  1287. filtered = append(filtered, acc)
  1288. }
  1289. }
  1290. accounts = filtered
  1291. }
  1292. // Collect unique models from all accounts
  1293. modelSet := make(map[string]struct{})
  1294. hasAnyMapping := false
  1295. for _, acc := range accounts {
  1296. // Passthrough routing accepts models independently of model_mapping. A stale
  1297. // mapping on any eligible passthrough account therefore cannot define the
  1298. // public whitelist; return nil so the handler uses its default model set.
  1299. if platform == PlatformOpenAI && acc.IsOpenAIPassthroughEnabled() {
  1300. if s.modelsListCache != nil {
  1301. s.modelsListCache.Set(cacheKey, []string(nil), s.modelsListCacheTTL)
  1302. modelsListCacheStoreTotal.Add(1)
  1303. }
  1304. return nil
  1305. }
  1306. mapping := acc.GetModelMapping()
  1307. if len(mapping) > 0 {
  1308. hasAnyMapping = true
  1309. for model := range mapping {
  1310. modelSet[model] = struct{}{}
  1311. }
  1312. }
  1313. }
  1314. // If no account has model_mapping, return nil (use default)
  1315. if !hasAnyMapping {
  1316. if s.modelsListCache != nil {
  1317. s.modelsListCache.Set(cacheKey, []string(nil), s.modelsListCacheTTL)
  1318. modelsListCacheStoreTotal.Add(1)
  1319. }
  1320. return nil
  1321. }
  1322. // Convert to slice
  1323. models := make([]string, 0, len(modelSet))
  1324. for model := range modelSet {
  1325. models = append(models, model)
  1326. }
  1327. sort.Strings(models)
  1328. if platform == PlatformOpenAI {
  1329. models = supplementUnmappedOpenAIModels(accounts, models)
  1330. }
  1331. if s.modelsListCache != nil {
  1332. s.modelsListCache.Set(cacheKey, cloneStringSlice(models), s.modelsListCacheTTL)
  1333. modelsListCacheStoreTotal.Add(1)
  1334. }
  1335. return cloneStringSlice(models)
  1336. }
  1337. func (s *GatewayService) resolveCompositeModelOwnership(ctx context.Context, groupID int64, model string) (CompositeModelOwnership, error) {
  1338. model = strings.TrimSpace(model)
  1339. if s == nil || s.accountRepo == nil || groupID <= 0 || model == "" {
  1340. return CompositeModelOwnership{}, nil
  1341. }
  1342. cacheKey := compositeModelOwnershipCacheKey(groupID, model)
  1343. if s.modelsListCache != nil {
  1344. if cached, found := s.modelsListCache.Get(cacheKey); found {
  1345. if ownership, ok := cached.(CompositeModelOwnership); ok {
  1346. return ownership, nil
  1347. }
  1348. }
  1349. }
  1350. accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID)
  1351. if err != nil {
  1352. return CompositeModelOwnership{}, err
  1353. }
  1354. platforms := make(map[string]struct{})
  1355. for _, account := range accounts {
  1356. platform := strings.TrimSpace(account.Platform)
  1357. if !isConcreteRequestPlatform(platform) || !explicitModelMappingClaims(account, model) {
  1358. continue
  1359. }
  1360. platforms[platform] = struct{}{}
  1361. }
  1362. ownership := CompositeModelOwnership{}
  1363. if len(platforms) == 1 {
  1364. for platform := range platforms {
  1365. ownership.TargetPlatform = platform
  1366. }
  1367. ownership.Matched = true
  1368. } else if len(platforms) > 1 {
  1369. ownership.Ambiguous = true
  1370. }
  1371. if s.modelsListCache != nil {
  1372. s.modelsListCache.Set(cacheKey, ownership, s.modelsListCacheTTL)
  1373. }
  1374. return ownership, nil
  1375. }
  1376. func explicitModelMappingClaims(account Account, model string) bool {
  1377. if account.Credentials == nil || model == "" {
  1378. return false
  1379. }
  1380. mapped, ok := stringMappingFromRaw(account.Credentials["model_mapping"])[model]
  1381. return ok && strings.TrimSpace(mapped) != ""
  1382. }
  1383. // GetSchedulablePlatforms returns the concrete platforms that currently have
  1384. // schedulable accounts in the target group.
  1385. func (s *GatewayService) GetSchedulablePlatforms(ctx context.Context, groupID *int64) map[string]struct{} {
  1386. platforms := make(map[string]struct{})
  1387. if s == nil || s.accountRepo == nil {
  1388. return platforms
  1389. }
  1390. var accounts []Account
  1391. var err error
  1392. if groupID != nil {
  1393. accounts, err = s.accountRepo.ListSchedulableByGroupID(ctx, *groupID)
  1394. } else {
  1395. accounts, err = s.accountRepo.ListSchedulable(ctx)
  1396. }
  1397. if err != nil {
  1398. return platforms
  1399. }
  1400. for _, acc := range accounts {
  1401. platform := strings.TrimSpace(acc.Platform)
  1402. if platform != "" {
  1403. platforms[platform] = struct{}{}
  1404. }
  1405. }
  1406. return platforms
  1407. }
  1408. func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform string) {
  1409. if s == nil || s.modelsListCache == nil {
  1410. return
  1411. }
  1412. s.invalidateCompositeModelOwnershipCache(groupID)
  1413. normalizedPlatform := strings.TrimSpace(platform)
  1414. // 完整匹配时精准失效;否则按维度批量失效。
  1415. if groupID != nil && normalizedPlatform != "" {
  1416. s.modelsListCache.Delete(modelsListCacheKey(groupID, normalizedPlatform))
  1417. return
  1418. }
  1419. targetGroup := derefGroupID(groupID)
  1420. for key := range s.modelsListCache.Items() {
  1421. parts := strings.SplitN(key, "|", 2)
  1422. if len(parts) != 2 {
  1423. continue
  1424. }
  1425. groupPart, parseErr := strconv.ParseInt(parts[0], 10, 64)
  1426. if parseErr != nil {
  1427. continue
  1428. }
  1429. if groupID != nil && groupPart != targetGroup {
  1430. continue
  1431. }
  1432. if normalizedPlatform != "" && parts[1] != normalizedPlatform {
  1433. continue
  1434. }
  1435. s.modelsListCache.Delete(key)
  1436. }
  1437. }
  1438. func (s *GatewayService) invalidateCompositeModelOwnershipCache(groupID *int64) {
  1439. for key := range s.modelsListCache.Items() {
  1440. if !strings.HasPrefix(key, compositeModelOwnershipCachePrefix) {
  1441. continue
  1442. }
  1443. if groupID == nil {
  1444. s.modelsListCache.Delete(key)
  1445. continue
  1446. }
  1447. parts := strings.SplitN(strings.TrimPrefix(key, compositeModelOwnershipCachePrefix), "|", 2)
  1448. if len(parts) != 2 {
  1449. continue
  1450. }
  1451. cachedGroupID, err := strconv.ParseInt(parts[0], 10, 64)
  1452. if err == nil && cachedGroupID == *groupID {
  1453. s.modelsListCache.Delete(key)
  1454. }
  1455. }
  1456. }
  1457. const debugGatewayBodyDefaultFilename = "gateway_debug.log"
  1458. // initDebugGatewayBodyFile 初始化网关调试日志文件。
  1459. //
  1460. // - "1"/"true" 等布尔值 → 当前目录下 gateway_debug.log
  1461. // - 已有目录路径 → 该目录下 gateway_debug.log
  1462. // - 其他 → 视为完整文件路径
  1463. func (s *GatewayService) initDebugGatewayBodyFile(path string) {
  1464. if parseDebugEnvBool(path) {
  1465. path = debugGatewayBodyDefaultFilename
  1466. }
  1467. // 如果 path 指向一个已存在的目录,自动追加默认文件名
  1468. if info, err := os.Stat(path); err == nil && info.IsDir() { //nolint:gosec // G703: path 仅来自启动环境变量 SUB2API_DEBUG_GATEWAY_BODY(运维配置),非请求输入
  1469. path = filepath.Join(path, debugGatewayBodyDefaultFilename)
  1470. }
  1471. // 确保父目录存在
  1472. if dir := filepath.Dir(path); dir != "." {
  1473. if err := os.MkdirAll(dir, 0755); err != nil { //nolint:gosec // G703: 同上
  1474. slog.Error("failed to create gateway debug log directory", "dir", dir, "error", err)
  1475. return
  1476. }
  1477. }
  1478. f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) //nolint:gosec // G703: 同上
  1479. if err != nil {
  1480. slog.Error("failed to open gateway debug log file", "path", path, "error", err)
  1481. return
  1482. }
  1483. s.debugGatewayBodyFile.Store(f)
  1484. slog.Info("gateway debug logging enabled", "path", path)
  1485. }
  1486. // debugLogGatewaySnapshot 将网关请求的完整快照(headers + body)写入独立的调试日志文件,
  1487. // 用于对比客户端原始请求和上游转发请求。
  1488. //
  1489. // 启用方式(环境变量):
  1490. //
  1491. // SUB2API_DEBUG_GATEWAY_BODY=1 # 写入 gateway_debug.log
  1492. // SUB2API_DEBUG_GATEWAY_BODY=/tmp/gateway_debug.log # 写入指定路径
  1493. //
  1494. // tag: "CLIENT_ORIGINAL" 或 "UPSTREAM_FORWARD"
  1495. func (s *GatewayService) debugLogGatewaySnapshot(tag string, headers http.Header, body []byte, extra map[string]string) {
  1496. f := s.debugGatewayBodyFile.Load()
  1497. if f == nil {
  1498. return
  1499. }
  1500. var buf strings.Builder
  1501. ts := time.Now().Format("2006-01-02 15:04:05.000")
  1502. fmt.Fprintf(&buf, "\n========== [%s] %s ==========\n", ts, tag)
  1503. // 1. context
  1504. if len(extra) > 0 {
  1505. fmt.Fprint(&buf, "--- context ---\n")
  1506. extraKeys := make([]string, 0, len(extra))
  1507. for k := range extra {
  1508. extraKeys = append(extraKeys, k)
  1509. }
  1510. sort.Strings(extraKeys)
  1511. for _, k := range extraKeys {
  1512. fmt.Fprintf(&buf, " %s: %s\n", k, extra[k])
  1513. }
  1514. }
  1515. // 2. headers(按真实 Claude CLI wire 顺序排列,便于与抓包对比;auth 脱敏)
  1516. fmt.Fprint(&buf, "--- headers ---\n")
  1517. for _, k := range sortHeadersByWireOrder(headers) {
  1518. for _, v := range headers[k] {
  1519. fmt.Fprintf(&buf, " %s: %s\n", k, safeHeaderValueForLog(k, v))
  1520. }
  1521. }
  1522. // 3. body(完整输出,格式化 JSON 便于 diff)
  1523. fmt.Fprint(&buf, "--- body ---\n")
  1524. if len(body) == 0 {
  1525. fmt.Fprint(&buf, " (empty)\n")
  1526. } else {
  1527. var pretty bytes.Buffer
  1528. if json.Indent(&pretty, body, " ", " ") == nil {
  1529. fmt.Fprintf(&buf, " %s\n", pretty.Bytes())
  1530. } else {
  1531. // JSON 格式化失败时原样输出
  1532. fmt.Fprintf(&buf, " %s\n", body)
  1533. }
  1534. }
  1535. // 写入文件(调试用,并发写入可能交错但不影响可读性)
  1536. _, _ = f.WriteString(buf.String())
  1537. }