gateway_anthropic_apikey_passthrough_test.go 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771
  1. package service
  2. import (
  3. "bufio"
  4. "bytes"
  5. "context"
  6. "encoding/json"
  7. "errors"
  8. "io"
  9. "net/http"
  10. "net/http/httptest"
  11. "strings"
  12. "testing"
  13. "time"
  14. "github.com/Wei-Shaw/sub2api/internal/config"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  16. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  17. "github.com/gin-gonic/gin"
  18. "github.com/stretchr/testify/require"
  19. "github.com/tidwall/gjson"
  20. )
  21. type anthropicHTTPUpstreamRecorder struct {
  22. lastReq *http.Request
  23. lastBody []byte
  24. resp *http.Response
  25. err error
  26. }
  27. func newAnthropicAPIKeyAccountForTest() *Account {
  28. return &Account{
  29. ID: 201,
  30. Name: "anthropic-apikey-pass-test",
  31. Platform: PlatformAnthropic,
  32. Type: AccountTypeAPIKey,
  33. Concurrency: 1,
  34. Credentials: map[string]any{
  35. "api_key": "upstream-anthropic-key",
  36. "base_url": "https://api.anthropic.com",
  37. },
  38. Extra: map[string]any{
  39. "anthropic_passthrough": true,
  40. },
  41. Status: StatusActive,
  42. Schedulable: true,
  43. }
  44. }
  45. func (u *anthropicHTTPUpstreamRecorder) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
  46. u.lastReq = req
  47. if req != nil && req.Body != nil {
  48. b, _ := io.ReadAll(req.Body)
  49. u.lastBody = b
  50. _ = req.Body.Close()
  51. req.Body = io.NopCloser(bytes.NewReader(b))
  52. }
  53. if u.err != nil {
  54. return nil, u.err
  55. }
  56. return u.resp, nil
  57. }
  58. func (u *anthropicHTTPUpstreamRecorder) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
  59. return u.Do(req, proxyURL, accountID, accountConcurrency)
  60. }
  61. type streamReadCloser struct {
  62. payload []byte
  63. sent bool
  64. err error
  65. }
  66. func (r *streamReadCloser) Read(p []byte) (int, error) {
  67. if !r.sent {
  68. r.sent = true
  69. n := copy(p, r.payload)
  70. return n, nil
  71. }
  72. if r.err != nil {
  73. return 0, r.err
  74. }
  75. return 0, io.EOF
  76. }
  77. func (r *streamReadCloser) Close() error { return nil }
  78. type failWriteResponseWriter struct {
  79. gin.ResponseWriter
  80. }
  81. func (w *failWriteResponseWriter) Write(data []byte) (int, error) {
  82. return 0, errors.New("client disconnected")
  83. }
  84. func (w *failWriteResponseWriter) WriteString(_ string) (int, error) {
  85. return 0, errors.New("client disconnected")
  86. }
  87. func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamPreservesBodyAndAuthReplacement(t *testing.T) {
  88. gin.SetMode(gin.TestMode)
  89. rec := httptest.NewRecorder()
  90. c, _ := gin.CreateTestContext(rec)
  91. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  92. c.Request.Header.Set("User-Agent", "claude-cli/1.0.0")
  93. c.Request.Header.Set("Authorization", "Bearer inbound-token")
  94. c.Request.Header.Set("X-Api-Key", "inbound-api-key")
  95. c.Request.Header.Set("X-Goog-Api-Key", "inbound-goog-key")
  96. c.Request.Header.Set("Cookie", "secret=1")
  97. c.Request.Header.Set("Anthropic-Beta", "interleaved-thinking-2025-05-14")
  98. body := []byte(`{"model":"claude-3-7-sonnet-20250219","stream":true,"system":[{"type":"text","text":"x-anthropic-billing-header keep"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  99. parsed := &ParsedRequest{
  100. Body: NewRequestBodyRef(body),
  101. Model: "claude-3-7-sonnet-20250219",
  102. Stream: true,
  103. }
  104. upstreamSSE := strings.Join([]string{
  105. `data: {"type":"message_start","message":{"usage":{"input_tokens":9,"cached_tokens":7}}}`,
  106. "",
  107. `data: {"type":"message_delta","usage":{"output_tokens":3}}`,
  108. "",
  109. "data: [DONE]",
  110. "",
  111. }, "\n")
  112. upstream := &anthropicHTTPUpstreamRecorder{
  113. resp: &http.Response{
  114. StatusCode: http.StatusOK,
  115. Header: http.Header{
  116. "Content-Type": []string{"text/event-stream"},
  117. "x-request-id": []string{"rid-anthropic-pass"},
  118. "Set-Cookie": []string{"secret=upstream"},
  119. },
  120. Body: io.NopCloser(strings.NewReader(upstreamSSE)),
  121. },
  122. }
  123. cfg := &config.Config{
  124. Gateway: config.GatewayConfig{
  125. MaxLineSize: defaultMaxLineSize,
  126. },
  127. }
  128. svc := &GatewayService{
  129. cfg: cfg,
  130. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  131. httpUpstream: upstream,
  132. rateLimitService: &RateLimitService{},
  133. deferredService: &DeferredService{},
  134. billingCacheService: nil,
  135. }
  136. account := &Account{
  137. ID: 101,
  138. Name: "anthropic-apikey-pass",
  139. Platform: PlatformAnthropic,
  140. Type: AccountTypeAPIKey,
  141. Concurrency: 1,
  142. Credentials: map[string]any{
  143. "api_key": "upstream-anthropic-key",
  144. "base_url": "https://api.anthropic.com",
  145. "model_mapping": map[string]any{"claude-3-7-sonnet-20250219": "claude-3-haiku-20240307"},
  146. },
  147. Extra: map[string]any{
  148. "anthropic_passthrough": true,
  149. },
  150. Status: StatusActive,
  151. Schedulable: true,
  152. }
  153. result, err := svc.Forward(context.Background(), c, account, parsed)
  154. require.NoError(t, err)
  155. require.NotNil(t, result)
  156. require.True(t, result.Stream)
  157. require.Equal(t, "claude-3-haiku-20240307", gjson.GetBytes(upstream.lastBody, "model").String(), "透传模式应应用账号级模型映射")
  158. require.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key"))
  159. require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization"))
  160. require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-goog-api-key"))
  161. require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie"))
  162. require.Equal(t, "2023-06-01", getHeaderRaw(upstream.lastReq.Header, "anthropic-version"))
  163. require.Equal(t, "interleaved-thinking-2025-05-14", getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"))
  164. require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-stainless-lang"), "API Key 透传不应注入 OAuth 指纹头")
  165. require.Contains(t, rec.Body.String(), `"cached_tokens":7`)
  166. require.NotContains(t, rec.Body.String(), `"cache_read_input_tokens":7`, "透传输出不应被网关改写")
  167. require.Equal(t, 7, result.Usage.CacheReadInputTokens, "计费 usage 解析应保留 cached_tokens 兼容")
  168. require.Empty(t, rec.Header().Get("Set-Cookie"), "响应头应经过安全过滤")
  169. }
  170. func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardCountTokensPreservesBody(t *testing.T) {
  171. gin.SetMode(gin.TestMode)
  172. rec := httptest.NewRecorder()
  173. c, _ := gin.CreateTestContext(rec)
  174. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  175. c.Request.Header.Set("Authorization", "Bearer inbound-token")
  176. c.Request.Header.Set("X-Api-Key", "inbound-api-key")
  177. c.Request.Header.Set("Cookie", "secret=1")
  178. body := []byte(`{"model":"claude-3-5-sonnet-latest","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}],"thinking":{"type":"enabled"}}`)
  179. parsed := &ParsedRequest{
  180. Body: NewRequestBodyRef(body),
  181. Model: "claude-3-5-sonnet-latest",
  182. }
  183. upstreamRespBody := `{"input_tokens":42}`
  184. upstream := &anthropicHTTPUpstreamRecorder{
  185. resp: &http.Response{
  186. StatusCode: http.StatusOK,
  187. Header: http.Header{
  188. "Content-Type": []string{"application/json"},
  189. "x-request-id": []string{"rid-count"},
  190. "Set-Cookie": []string{"secret=upstream"},
  191. },
  192. Body: io.NopCloser(strings.NewReader(upstreamRespBody)),
  193. },
  194. }
  195. cfg := &config.Config{
  196. Gateway: config.GatewayConfig{
  197. MaxLineSize: defaultMaxLineSize,
  198. },
  199. }
  200. svc := &GatewayService{
  201. cfg: cfg,
  202. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  203. httpUpstream: upstream,
  204. rateLimitService: &RateLimitService{},
  205. }
  206. account := &Account{
  207. ID: 102,
  208. Name: "anthropic-apikey-pass-count",
  209. Platform: PlatformAnthropic,
  210. Type: AccountTypeAPIKey,
  211. Concurrency: 1,
  212. Credentials: map[string]any{
  213. "api_key": "upstream-anthropic-key",
  214. "base_url": "https://api.anthropic.com",
  215. "model_mapping": map[string]any{"claude-3-5-sonnet-latest": "claude-3-opus-20240229"},
  216. },
  217. Extra: map[string]any{
  218. "anthropic_passthrough": true,
  219. },
  220. Status: StatusActive,
  221. Schedulable: true,
  222. }
  223. err := svc.ForwardCountTokens(context.Background(), c, account, parsed)
  224. require.NoError(t, err)
  225. require.Equal(t, "claude-3-opus-20240229", gjson.GetBytes(upstream.lastBody, "model").String(), "count_tokens 透传模式应应用账号级模型映射")
  226. require.Equal(t, "upstream-anthropic-key", getHeaderRaw(upstream.lastReq.Header, "x-api-key"))
  227. require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "authorization"))
  228. require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "cookie"))
  229. require.Equal(t, http.StatusOK, rec.Code)
  230. require.JSONEq(t, upstreamRespBody, rec.Body.String())
  231. require.Empty(t, rec.Header().Get("Set-Cookie"))
  232. }
  233. func TestGatewayService_AnthropicAPIKeyPassthrough_BearerAuthScheme(t *testing.T) {
  234. gin.SetMode(gin.TestMode)
  235. rec := httptest.NewRecorder()
  236. c, _ := gin.CreateTestContext(rec)
  237. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  238. c.Request.Header.Set("Authorization", "Bearer inbound-token")
  239. c.Request.Header.Set("X-Api-Key", "inbound-api-key")
  240. c.Request.Header.Set("Cookie", "secret=1")
  241. svc := &GatewayService{
  242. cfg: &config.Config{
  243. Security: config.SecurityConfig{
  244. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  245. },
  246. },
  247. }
  248. account := &Account{
  249. Platform: PlatformAnthropic,
  250. Type: AccountTypeAPIKey,
  251. Credentials: map[string]any{
  252. "api_key": "ollama-key",
  253. "base_url": "https://ollama.com",
  254. },
  255. Extra: map[string]any{
  256. "anthropic_passthrough": true,
  257. "anthropic_apikey_auth_scheme": AnthropicAPIKeyAuthSchemeAuthorizationBearer,
  258. },
  259. }
  260. msgReq, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(
  261. context.Background(), c, account, []byte(`{"model":"gpt-oss:20b","messages":[]}`), "ollama-key",
  262. )
  263. require.NoError(t, err)
  264. require.Equal(t, "https://ollama.com/v1/messages?beta=true", msgReq.URL.String())
  265. require.JSONEq(t, `{"model":"gpt-oss:20b","messages":[]}`, string(wireBody))
  266. require.Equal(t, "Bearer ollama-key", getHeaderRaw(msgReq.Header, "authorization"))
  267. require.Empty(t, getHeaderRaw(msgReq.Header, "x-api-key"))
  268. require.Empty(t, getHeaderRaw(msgReq.Header, "cookie"))
  269. countReq, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(
  270. context.Background(), c, account, []byte(`{"model":"gpt-oss:20b","messages":[]}`), "ollama-key",
  271. )
  272. require.NoError(t, err)
  273. require.Equal(t, "https://ollama.com/v1/messages/count_tokens?beta=true", countReq.URL.String())
  274. require.Equal(t, "Bearer ollama-key", getHeaderRaw(countReq.Header, "authorization"))
  275. require.Empty(t, getHeaderRaw(countReq.Header, "x-api-key"))
  276. require.Empty(t, getHeaderRaw(countReq.Header, "cookie"))
  277. }
  278. // TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingEdgeCases 覆盖透传模式下模型映射的各种边界情况
  279. func TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingEdgeCases(t *testing.T) {
  280. gin.SetMode(gin.TestMode)
  281. tests := []struct {
  282. name string
  283. model string
  284. modelMapping map[string]any // nil = 不配置映射
  285. expectedModel string
  286. endpoint string // "messages" or "count_tokens"
  287. }{
  288. {
  289. name: "Forward: 无映射配置时不改写模型",
  290. model: "claude-sonnet-4-20250514",
  291. modelMapping: nil,
  292. expectedModel: "claude-sonnet-4-20250514",
  293. endpoint: "messages",
  294. },
  295. {
  296. name: "Forward: 空映射配置时不改写模型",
  297. model: "claude-sonnet-4-20250514",
  298. modelMapping: map[string]any{},
  299. expectedModel: "claude-sonnet-4-20250514",
  300. endpoint: "messages",
  301. },
  302. {
  303. name: "Forward: 模型不在映射表中时不改写",
  304. model: "claude-sonnet-4-20250514",
  305. modelMapping: map[string]any{"claude-3-haiku-20240307": "claude-3-opus-20240229"},
  306. expectedModel: "claude-sonnet-4-20250514",
  307. endpoint: "messages",
  308. },
  309. {
  310. name: "Forward: 精确匹配映射应改写模型",
  311. model: "claude-sonnet-4-20250514",
  312. modelMapping: map[string]any{"claude-sonnet-4-20250514": "claude-sonnet-4-5-20241022"},
  313. expectedModel: "claude-sonnet-4-5-20241022",
  314. endpoint: "messages",
  315. },
  316. {
  317. name: "Forward: 通配符映射应改写模型",
  318. model: "claude-sonnet-4-20250514",
  319. modelMapping: map[string]any{"claude-sonnet-4-*": "claude-sonnet-4-5-20241022"},
  320. expectedModel: "claude-sonnet-4-5-20241022",
  321. endpoint: "messages",
  322. },
  323. {
  324. name: "CountTokens: 无映射配置时不改写模型",
  325. model: "claude-sonnet-4-20250514",
  326. modelMapping: nil,
  327. expectedModel: "claude-sonnet-4-20250514",
  328. endpoint: "count_tokens",
  329. },
  330. {
  331. name: "CountTokens: 模型不在映射表中时不改写",
  332. model: "claude-sonnet-4-20250514",
  333. modelMapping: map[string]any{"claude-3-haiku-20240307": "claude-3-opus-20240229"},
  334. expectedModel: "claude-sonnet-4-20250514",
  335. endpoint: "count_tokens",
  336. },
  337. {
  338. name: "CountTokens: 精确匹配映射应改写模型",
  339. model: "claude-sonnet-4-20250514",
  340. modelMapping: map[string]any{"claude-sonnet-4-20250514": "claude-sonnet-4-5-20241022"},
  341. expectedModel: "claude-sonnet-4-5-20241022",
  342. endpoint: "count_tokens",
  343. },
  344. {
  345. name: "CountTokens: 通配符映射应改写模型",
  346. model: "claude-sonnet-4-20250514",
  347. modelMapping: map[string]any{"claude-sonnet-4-*": "claude-sonnet-4-5-20241022"},
  348. expectedModel: "claude-sonnet-4-5-20241022",
  349. endpoint: "count_tokens",
  350. },
  351. }
  352. for _, tt := range tests {
  353. t.Run(tt.name, func(t *testing.T) {
  354. rec := httptest.NewRecorder()
  355. c, _ := gin.CreateTestContext(rec)
  356. body := []byte(`{"model":"` + tt.model + `","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  357. parsed := &ParsedRequest{
  358. Body: NewRequestBodyRef(body),
  359. Model: tt.model,
  360. }
  361. credentials := map[string]any{
  362. "api_key": "upstream-key",
  363. "base_url": "https://api.anthropic.com",
  364. }
  365. if tt.modelMapping != nil {
  366. credentials["model_mapping"] = tt.modelMapping
  367. }
  368. account := &Account{
  369. ID: 300,
  370. Name: "edge-case-test",
  371. Platform: PlatformAnthropic,
  372. Type: AccountTypeAPIKey,
  373. Concurrency: 1,
  374. Credentials: credentials,
  375. Extra: map[string]any{"anthropic_passthrough": true},
  376. Status: StatusActive,
  377. Schedulable: true,
  378. }
  379. if tt.endpoint == "messages" {
  380. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  381. parsed.Stream = false
  382. upstreamJSON := `{"id":"msg_1","type":"message","usage":{"input_tokens":5,"output_tokens":3}}`
  383. upstream := &anthropicHTTPUpstreamRecorder{
  384. resp: &http.Response{
  385. StatusCode: http.StatusOK,
  386. Header: http.Header{"Content-Type": []string{"application/json"}},
  387. Body: io.NopCloser(strings.NewReader(upstreamJSON)),
  388. },
  389. }
  390. svc := &GatewayService{
  391. cfg: &config.Config{},
  392. httpUpstream: upstream,
  393. rateLimitService: &RateLimitService{},
  394. }
  395. result, err := svc.Forward(context.Background(), c, account, parsed)
  396. require.NoError(t, err)
  397. require.NotNil(t, result)
  398. require.Equal(t, tt.expectedModel, gjson.GetBytes(upstream.lastBody, "model").String(),
  399. "Forward 上游请求体中的模型应为: %s", tt.expectedModel)
  400. } else {
  401. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  402. upstreamRespBody := `{"input_tokens":42}`
  403. upstream := &anthropicHTTPUpstreamRecorder{
  404. resp: &http.Response{
  405. StatusCode: http.StatusOK,
  406. Header: http.Header{"Content-Type": []string{"application/json"}},
  407. Body: io.NopCloser(strings.NewReader(upstreamRespBody)),
  408. },
  409. }
  410. svc := &GatewayService{
  411. cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}},
  412. httpUpstream: upstream,
  413. rateLimitService: &RateLimitService{},
  414. }
  415. err := svc.ForwardCountTokens(context.Background(), c, account, parsed)
  416. require.NoError(t, err)
  417. require.Equal(t, tt.expectedModel, gjson.GetBytes(upstream.lastBody, "model").String(),
  418. "CountTokens 上游请求体中的模型应为: %s", tt.expectedModel)
  419. }
  420. })
  421. }
  422. }
  423. // TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingPreservesOtherFields
  424. // 确保模型映射只替换 model 字段,不影响请求体中的其他字段
  425. func TestGatewayService_AnthropicAPIKeyPassthrough_ModelMappingPreservesOtherFields(t *testing.T) {
  426. gin.SetMode(gin.TestMode)
  427. rec := httptest.NewRecorder()
  428. c, _ := gin.CreateTestContext(rec)
  429. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  430. // 包含复杂字段的请求体:system、thinking、messages
  431. body := []byte(`{"model":"claude-sonnet-4-20250514","system":[{"type":"text","text":"You are a helpful assistant."}],"messages":[{"role":"user","content":[{"type":"text","text":"hello world"}]}],"thinking":{"type":"enabled","budget_tokens":5000},"max_tokens":1024}`)
  432. parsed := &ParsedRequest{
  433. Body: NewRequestBodyRef(body),
  434. Model: "claude-sonnet-4-20250514",
  435. }
  436. upstreamRespBody := `{"input_tokens":42}`
  437. upstream := &anthropicHTTPUpstreamRecorder{
  438. resp: &http.Response{
  439. StatusCode: http.StatusOK,
  440. Header: http.Header{"Content-Type": []string{"application/json"}},
  441. Body: io.NopCloser(strings.NewReader(upstreamRespBody)),
  442. },
  443. }
  444. svc := &GatewayService{
  445. cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}},
  446. httpUpstream: upstream,
  447. rateLimitService: &RateLimitService{},
  448. }
  449. account := &Account{
  450. ID: 301,
  451. Name: "preserve-fields-test",
  452. Platform: PlatformAnthropic,
  453. Type: AccountTypeAPIKey,
  454. Concurrency: 1,
  455. Credentials: map[string]any{
  456. "api_key": "upstream-key",
  457. "base_url": "https://api.anthropic.com",
  458. "model_mapping": map[string]any{"claude-sonnet-4-20250514": "claude-sonnet-4-5-20241022"},
  459. },
  460. Extra: map[string]any{"anthropic_passthrough": true},
  461. Status: StatusActive,
  462. Schedulable: true,
  463. }
  464. err := svc.ForwardCountTokens(context.Background(), c, account, parsed)
  465. require.NoError(t, err)
  466. sentBody := upstream.lastBody
  467. require.Equal(t, "claude-sonnet-4-5-20241022", gjson.GetBytes(sentBody, "model").String(), "model 应被映射")
  468. require.Equal(t, "You are a helpful assistant.", gjson.GetBytes(sentBody, "system.0.text").String(), "system 字段不应被修改")
  469. require.Equal(t, "hello world", gjson.GetBytes(sentBody, "messages.0.content.0.text").String(), "messages 字段不应被修改")
  470. require.Equal(t, "enabled", gjson.GetBytes(sentBody, "thinking.type").String(), "thinking 字段不应被修改")
  471. require.Equal(t, int64(5000), gjson.GetBytes(sentBody, "thinking.budget_tokens").Int(), "thinking.budget_tokens 不应被修改")
  472. require.False(t, gjson.GetBytes(sentBody, "max_tokens").Exists(),
  473. "max_tokens 作为生成参数应被 count_tokens 过滤剥离")
  474. }
  475. func TestGatewayService_AnthropicAPIKeyPassthrough_CountTokensFiltersGenerationFields(t *testing.T) {
  476. gin.SetMode(gin.TestMode)
  477. rec := httptest.NewRecorder()
  478. c, _ := gin.CreateTestContext(rec)
  479. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  480. body := []byte(`{"model":"claude-sonnet-4-20250514","system":[{"type":"text","text":"sys"}],"messages":[{"role":"user","content":"hello"}],"tools":[{"name":"tool","input_schema":{"type":"object"}}],"temperature":0.7,"top_p":0.9,"top_k":40,"stream":true,"stop_sequences":["END"],"max_tokens":1024,"thinking":{"type":"enabled","budget_tokens":5000}}`)
  481. parsed := &ParsedRequest{
  482. Body: NewRequestBodyRef(body),
  483. Model: "claude-sonnet-4-20250514",
  484. }
  485. upstreamRespBody := `{"input_tokens":42}`
  486. upstream := &anthropicHTTPUpstreamRecorder{
  487. resp: &http.Response{
  488. StatusCode: http.StatusOK,
  489. Header: http.Header{"Content-Type": []string{"application/json"}},
  490. Body: io.NopCloser(strings.NewReader(upstreamRespBody)),
  491. },
  492. }
  493. svc := &GatewayService{
  494. cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}},
  495. httpUpstream: upstream,
  496. rateLimitService: &RateLimitService{},
  497. }
  498. account := &Account{
  499. ID: 302,
  500. Name: "count-token-filter-test",
  501. Platform: PlatformAnthropic,
  502. Type: AccountTypeAPIKey,
  503. Concurrency: 1,
  504. Credentials: map[string]any{
  505. "api_key": "upstream-key",
  506. "base_url": "https://api.anthropic.com",
  507. },
  508. Extra: map[string]any{"anthropic_passthrough": true},
  509. Status: StatusActive,
  510. Schedulable: true,
  511. }
  512. err := svc.ForwardCountTokens(context.Background(), c, account, parsed)
  513. require.NoError(t, err)
  514. sentBody := upstream.lastBody
  515. require.False(t, gjson.GetBytes(sentBody, "temperature").Exists())
  516. require.False(t, gjson.GetBytes(sentBody, "top_p").Exists())
  517. require.False(t, gjson.GetBytes(sentBody, "top_k").Exists())
  518. require.False(t, gjson.GetBytes(sentBody, "stream").Exists())
  519. require.False(t, gjson.GetBytes(sentBody, "stop_sequences").Exists())
  520. require.Equal(t, "claude-sonnet-4-20250514", gjson.GetBytes(sentBody, "model").String())
  521. require.Equal(t, "sys", gjson.GetBytes(sentBody, "system.0.text").String())
  522. require.Equal(t, "hello", gjson.GetBytes(sentBody, "messages.0.content").String())
  523. require.Equal(t, "tool", gjson.GetBytes(sentBody, "tools.0.name").String())
  524. require.False(t, gjson.GetBytes(sentBody, "max_tokens").Exists(),
  525. "count_tokens 请求不得携带生成参数 max_tokens")
  526. require.Equal(t, "enabled", gjson.GetBytes(sentBody, "thinking.type").String())
  527. }
  528. // TestGatewayService_AnthropicAPIKeyPassthrough_EmptyModelSkipsMapping
  529. // 确保空模型名不会触发映射逻辑
  530. func TestGatewayService_AnthropicAPIKeyPassthrough_EmptyModelSkipsMapping(t *testing.T) {
  531. gin.SetMode(gin.TestMode)
  532. rec := httptest.NewRecorder()
  533. c, _ := gin.CreateTestContext(rec)
  534. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  535. body := []byte(`{"messages":[{"role":"user","content":"hello"}]}`)
  536. parsed := &ParsedRequest{
  537. Body: NewRequestBodyRef(body),
  538. Model: "", // 空模型
  539. }
  540. upstreamRespBody := `{"input_tokens":10}`
  541. upstream := &anthropicHTTPUpstreamRecorder{
  542. resp: &http.Response{
  543. StatusCode: http.StatusOK,
  544. Header: http.Header{"Content-Type": []string{"application/json"}},
  545. Body: io.NopCloser(strings.NewReader(upstreamRespBody)),
  546. },
  547. }
  548. svc := &GatewayService{
  549. cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}},
  550. httpUpstream: upstream,
  551. rateLimitService: &RateLimitService{},
  552. }
  553. account := &Account{
  554. ID: 302,
  555. Name: "empty-model-test",
  556. Platform: PlatformAnthropic,
  557. Type: AccountTypeAPIKey,
  558. Concurrency: 1,
  559. Credentials: map[string]any{
  560. "api_key": "upstream-key",
  561. "base_url": "https://api.anthropic.com",
  562. "model_mapping": map[string]any{"*": "claude-3-opus-20240229"},
  563. },
  564. Extra: map[string]any{"anthropic_passthrough": true},
  565. Status: StatusActive,
  566. Schedulable: true,
  567. }
  568. err := svc.ForwardCountTokens(context.Background(), c, account, parsed)
  569. require.NoError(t, err)
  570. // 空模型名时,body 应原样透传,不应触发映射
  571. require.Equal(t, body, upstream.lastBody, "空模型名时请求体不应被修改")
  572. }
  573. func TestGatewayService_AnthropicAPIKeyPassthrough_CountTokens404PassthroughNotError(t *testing.T) {
  574. gin.SetMode(gin.TestMode)
  575. tests := []struct {
  576. name string
  577. statusCode int
  578. respBody string
  579. wantPassthrough bool
  580. }{
  581. {
  582. name: "404 endpoint not found passes through as 404",
  583. statusCode: http.StatusNotFound,
  584. respBody: `{"error":{"message":"Not found: /v1/messages/count_tokens","type":"not_found_error"}}`,
  585. wantPassthrough: true,
  586. },
  587. {
  588. name: "404 generic not found does not passthrough",
  589. statusCode: http.StatusNotFound,
  590. respBody: `{"error":{"message":"resource not found","type":"not_found_error"}}`,
  591. wantPassthrough: false,
  592. },
  593. {
  594. name: "400 Invalid URL does not passthrough",
  595. statusCode: http.StatusBadRequest,
  596. respBody: `{"error":{"message":"Invalid URL (POST /v1/messages/count_tokens)","type":"invalid_request_error"}}`,
  597. wantPassthrough: false,
  598. },
  599. {
  600. name: "400 model error does not passthrough",
  601. statusCode: http.StatusBadRequest,
  602. respBody: `{"error":{"message":"model not found: claude-unknown","type":"invalid_request_error"}}`,
  603. wantPassthrough: false,
  604. },
  605. {
  606. name: "500 internal error does not passthrough",
  607. statusCode: http.StatusInternalServerError,
  608. respBody: `{"error":{"message":"internal error","type":"api_error"}}`,
  609. wantPassthrough: false,
  610. },
  611. }
  612. for _, tt := range tests {
  613. t.Run(tt.name, func(t *testing.T) {
  614. rec := httptest.NewRecorder()
  615. c, _ := gin.CreateTestContext(rec)
  616. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
  617. body := []byte(`{"model":"claude-sonnet-4-5-20250929","messages":[{"role":"user","content":"hi"}]}`)
  618. parsed := &ParsedRequest{Body: NewRequestBodyRef(body), Model: "claude-sonnet-4-5-20250929"}
  619. upstream := &anthropicHTTPUpstreamRecorder{
  620. resp: &http.Response{
  621. StatusCode: tt.statusCode,
  622. Header: http.Header{"Content-Type": []string{"application/json"}},
  623. Body: io.NopCloser(strings.NewReader(tt.respBody)),
  624. },
  625. }
  626. svc := &GatewayService{
  627. cfg: &config.Config{
  628. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  629. },
  630. httpUpstream: upstream,
  631. rateLimitService: nil,
  632. }
  633. account := &Account{
  634. ID: 200,
  635. Name: "proxy-acc",
  636. Platform: PlatformAnthropic,
  637. Type: AccountTypeAPIKey,
  638. Concurrency: 1,
  639. Credentials: map[string]any{
  640. "api_key": "sk-proxy",
  641. "base_url": "https://proxy.example.com",
  642. },
  643. Extra: map[string]any{"anthropic_passthrough": true},
  644. Status: StatusActive,
  645. Schedulable: true,
  646. }
  647. err := svc.ForwardCountTokens(context.Background(), c, account, parsed)
  648. if tt.wantPassthrough {
  649. // 返回 nil(不记录为错误),HTTP 状态码 404 + Anthropic 错误体
  650. require.NoError(t, err)
  651. require.Equal(t, http.StatusNotFound, rec.Code)
  652. var errResp map[string]any
  653. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errResp))
  654. require.Equal(t, "error", errResp["type"])
  655. errObj, ok := errResp["error"].(map[string]any)
  656. require.True(t, ok)
  657. require.Equal(t, "not_found_error", errObj["type"])
  658. } else {
  659. require.Error(t, err)
  660. require.Equal(t, tt.statusCode, rec.Code)
  661. }
  662. })
  663. }
  664. }
  665. func TestGatewayService_AnthropicAPIKeyPassthrough_BuildRequestRejectsInvalidBaseURL(t *testing.T) {
  666. gin.SetMode(gin.TestMode)
  667. rec := httptest.NewRecorder()
  668. c, _ := gin.CreateTestContext(rec)
  669. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  670. svc := &GatewayService{
  671. cfg: &config.Config{
  672. Security: config.SecurityConfig{
  673. URLAllowlist: config.URLAllowlistConfig{
  674. Enabled: false,
  675. },
  676. },
  677. },
  678. }
  679. account := &Account{
  680. Platform: PlatformAnthropic,
  681. Type: AccountTypeAPIKey,
  682. Credentials: map[string]any{
  683. "api_key": "k",
  684. "base_url": "://invalid-url",
  685. },
  686. }
  687. _, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, []byte(`{}`), "k")
  688. require.Error(t, err)
  689. }
  690. func TestGatewayService_AnthropicAPIKeyPassthrough_StripsDeferredToolCacheControl(t *testing.T) {
  691. gin.SetMode(gin.TestMode)
  692. rec := httptest.NewRecorder()
  693. c, _ := gin.CreateTestContext(rec)
  694. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  695. svc := &GatewayService{cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}}
  696. account := &Account{Platform: PlatformAnthropic, Type: AccountTypeAPIKey}
  697. body := []byte(`{"tools":[{"name":"deferred","custom":{"defer_loading":true},"cache_control":{"type":"ephemeral"}},{"name":"top-level-deferred","defer_loading":true,"cache_control":{"type":"ephemeral"}},{"name":"ordinary","defer_loading":false,"cache_control":{"type":"ephemeral"}},{"name":"malformed","defer_loading":"true","cache_control":{"type":"ephemeral"}}]}`)
  698. _, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "k")
  699. require.NoError(t, err)
  700. require.False(t, gjson.GetBytes(wireBody, "tools.0.cache_control").Exists())
  701. require.False(t, gjson.GetBytes(wireBody, "tools.1.cache_control").Exists())
  702. require.True(t, gjson.GetBytes(wireBody, "tools.2.cache_control").Exists())
  703. require.True(t, gjson.GetBytes(wireBody, "tools.3.cache_control").Exists())
  704. countReq, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "k")
  705. require.NoError(t, err)
  706. countBody, err := io.ReadAll(countReq.Body)
  707. require.NoError(t, err)
  708. require.False(t, gjson.GetBytes(countBody, "tools.0.cache_control").Exists())
  709. require.False(t, gjson.GetBytes(countBody, "tools.1.cache_control").Exists())
  710. require.True(t, gjson.GetBytes(countBody, "tools.2.cache_control").Exists())
  711. require.True(t, gjson.GetBytes(countBody, "tools.3.cache_control").Exists())
  712. }
  713. func TestGatewayService_AnthropicOAuth_NotAffectedByAPIKeyPassthroughToggle(t *testing.T) {
  714. gin.SetMode(gin.TestMode)
  715. rec := httptest.NewRecorder()
  716. c, _ := gin.CreateTestContext(rec)
  717. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  718. svc := &GatewayService{
  719. cfg: &config.Config{
  720. Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
  721. },
  722. }
  723. account := &Account{
  724. Platform: PlatformAnthropic,
  725. Type: AccountTypeOAuth,
  726. Extra: map[string]any{
  727. "anthropic_passthrough": true,
  728. },
  729. }
  730. require.False(t, account.IsAnthropicAPIKeyPassthroughEnabled())
  731. req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, []byte(`{"model":"claude-3-7-sonnet-20250219"}`), "oauth-token", "oauth", "claude-3-7-sonnet-20250219", true, false)
  732. require.NoError(t, err)
  733. require.Equal(t, "Bearer oauth-token", getHeaderRaw(req.Header, "authorization"))
  734. require.Contains(t, getHeaderRaw(req.Header, "anthropic-beta"), claude.BetaOAuth, "OAuth 链路仍应按原逻辑补齐 oauth beta")
  735. }
  736. func TestGatewayService_AnthropicOAuthMimic_RewritesSystemWithBillingBlock(t *testing.T) {
  737. gin.SetMode(gin.TestMode)
  738. tests := []struct {
  739. name string
  740. body string
  741. wantModel string
  742. wantOriginalSystem string
  743. wantOriginalSystemCacheTTL string
  744. wantMetadataUserID string
  745. }{
  746. {
  747. name: "sonnet system array",
  748. body: `{"model":"claude-3-5-sonnet-latest","system":[{"type":"text","text":"x-anthropic-billing-header keep"}],"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`,
  749. wantModel: "claude-3-5-sonnet-latest",
  750. wantOriginalSystem: "x-anthropic-billing-header keep",
  751. },
  752. {
  753. name: "sonnet system string",
  754. body: `{"model":"claude-3-5-sonnet-latest","system":"x-anthropic-billing-header keep","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`,
  755. wantModel: "claude-3-5-sonnet-latest",
  756. wantOriginalSystem: "x-anthropic-billing-header keep",
  757. },
  758. {
  759. name: "haiku full mimicry",
  760. body: `{"model":"claude-haiku-4-5","metadata":{"user_id":"pi-session-metadata"},"system":[{"type":"text","text":"Pi project instructions","cache_control":{"type":"ephemeral","ttl":"1h"}}],"thinking":{"type":"enabled","budget_tokens":1024},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`,
  761. wantModel: "claude-haiku-4-5-20251001",
  762. wantOriginalSystem: "Pi project instructions",
  763. wantOriginalSystemCacheTTL: "1h",
  764. wantMetadataUserID: "pi-session-metadata",
  765. },
  766. }
  767. for _, tt := range tests {
  768. t.Run(tt.name, func(t *testing.T) {
  769. rec := httptest.NewRecorder()
  770. c, _ := gin.CreateTestContext(rec)
  771. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  772. c.Request.Header.Set("User-Agent", "pi/0.51.0")
  773. c.Request.Header.Set("Anthropic-Beta", "client-only-beta")
  774. parsed, err := ParseGatewayRequest(NewRequestBodyRef([]byte(tt.body)), PlatformAnthropic)
  775. require.NoError(t, err)
  776. upstream := &anthropicHTTPUpstreamRecorder{
  777. resp: &http.Response{
  778. StatusCode: http.StatusOK,
  779. Header: http.Header{
  780. "Content-Type": []string{"application/json"},
  781. "x-request-id": []string{"rid-oauth-mimic"},
  782. },
  783. Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-haiku-4-5-20251001","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)),
  784. },
  785. }
  786. cfg := &config.Config{
  787. Gateway: config.GatewayConfig{
  788. MaxLineSize: defaultMaxLineSize,
  789. },
  790. }
  791. svc := &GatewayService{
  792. cfg: cfg,
  793. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  794. httpUpstream: upstream,
  795. rateLimitService: &RateLimitService{},
  796. deferredService: &DeferredService{},
  797. }
  798. account := &Account{
  799. ID: 301,
  800. Name: "anthropic-oauth-mimic",
  801. Platform: PlatformAnthropic,
  802. Type: AccountTypeOAuth,
  803. Concurrency: 1,
  804. Credentials: map[string]any{
  805. "access_token": "oauth-token",
  806. },
  807. Status: StatusActive,
  808. Schedulable: true,
  809. }
  810. result, err := svc.Forward(context.Background(), c, account, parsed)
  811. require.NoError(t, err)
  812. require.NotNil(t, result)
  813. require.NotNil(t, upstream.lastReq)
  814. require.Equal(t, "Bearer oauth-token", getHeaderRaw(upstream.lastReq.Header, "authorization"))
  815. finalBeta := getHeaderRaw(upstream.lastReq.Header, "anthropic-beta")
  816. for _, beta := range claude.FullClaudeCodeMimicryBetas() {
  817. require.Truef(t, anthropicBetaTokensContains(finalBeta, beta), "missing mimic beta %s", beta)
  818. }
  819. require.False(t, anthropicBetaTokensContains(finalBeta, "client-only-beta"))
  820. for key, value := range claude.DefaultHeaders {
  821. require.Equal(t, value, getHeaderRaw(upstream.lastReq.Header, key), "mimic fingerprint header %s", key)
  822. }
  823. require.NotEmpty(t, getHeaderRaw(upstream.lastReq.Header, "x-client-request-id"))
  824. require.Equal(t, tt.wantModel, gjson.GetBytes(upstream.lastBody, "model").String())
  825. system := gjson.GetBytes(upstream.lastBody, "system")
  826. require.True(t, system.Exists())
  827. require.True(t, system.IsArray(), "system should be an array")
  828. arr := system.Array()
  829. require.Len(t, arr, 3, "system array should have billing block + cc prompt block + expansion block")
  830. billingText := arr[0].Get("text").String()
  831. require.Contains(t, billingText, "x-anthropic-billing-header:")
  832. require.Contains(t, billingText, "cc_version="+claude.CLICurrentVersion+".")
  833. require.Contains(t, billingText, "cc_entrypoint=cli;")
  834. require.Equal(t, claudeCodeSystemPrompt, arr[1].Get("text").String())
  835. require.False(t, arr[1].Get("cache_control").Exists(), "身份前缀 block 不应带 cache_control")
  836. require.Equal(t, claudeCodeSystemPromptExpansion, arr[2].Get("text").String())
  837. require.Equal(t, "ephemeral", arr[2].Get("cache_control.type").String())
  838. // 原始 system prompt 应迁移至 messages 中。
  839. messages := gjson.GetBytes(upstream.lastBody, "messages")
  840. require.True(t, messages.IsArray())
  841. firstMsg := messages.Array()[0]
  842. require.Equal(t, "user", firstMsg.Get("role").String())
  843. require.Contains(t, firstMsg.Get("content.0.text").String(), tt.wantOriginalSystem)
  844. if tt.wantOriginalSystemCacheTTL != "" {
  845. require.Equal(t, "ephemeral", firstMsg.Get("content.0.cache_control.type").String())
  846. require.Equal(t, tt.wantOriginalSystemCacheTTL, firstMsg.Get("content.0.cache_control.ttl").String())
  847. } else {
  848. require.False(t, firstMsg.Get("content.0.cache_control").Exists())
  849. }
  850. if tt.wantMetadataUserID != "" {
  851. require.Equal(t, tt.wantMetadataUserID, gjson.GetBytes(upstream.lastBody, "metadata.user_id").String())
  852. require.True(t, gjson.GetBytes(upstream.lastBody, "context_management").Exists())
  853. }
  854. })
  855. }
  856. }
  857. func TestGatewayService_AnthropicOAuthRealClaudeCodeHaiku_PreservesClientHeadersAndBody(t *testing.T) {
  858. gin.SetMode(gin.TestMode)
  859. metadataUserID := FormatMetadataUserID(
  860. "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
  861. "550e8400-e29b-41d4-a716-446655440000",
  862. "123e4567-e89b-42d3-a456-426614174000",
  863. claude.CLICurrentVersion,
  864. )
  865. body := []byte(`{"model":"claude-haiku-4-5-20251001","metadata":{"user_id":` + strconvQuote(metadataUserID) + `},"system":[{"type":"text","text":"Client-owned Claude Code system","cache_control":{"type":"ephemeral"}}],"context_management":{"edits":[{"type":"clear_thinking_20251015","keep":"all"}]},"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  866. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
  867. require.NoError(t, err)
  868. rec := httptest.NewRecorder()
  869. c, _ := gin.CreateTestContext(rec)
  870. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  871. c.Request.Header.Set("User-Agent", "claude-cli/"+claude.CLICurrentVersion+" (external, cli)")
  872. c.Request.Header.Set("X-Stainless-Package-Version", "real-client-package")
  873. clientBeta := strings.Join([]string{
  874. claude.BetaClaudeCode,
  875. claude.BetaOAuth,
  876. claude.BetaInterleavedThinking,
  877. claude.BetaContextManagement,
  878. }, ",")
  879. c.Request.Header.Set("Anthropic-Beta", clientBeta)
  880. upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
  881. StatusCode: http.StatusOK,
  882. Header: http.Header{"Content-Type": []string{"application/json"}},
  883. Body: io.NopCloser(strings.NewReader(`{"id":"msg_real_cc","type":"message","role":"assistant","model":"claude-haiku-4-5-20251001","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)),
  884. }}
  885. cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
  886. svc := &GatewayService{
  887. cfg: cfg,
  888. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  889. httpUpstream: upstream,
  890. rateLimitService: &RateLimitService{},
  891. deferredService: &DeferredService{},
  892. }
  893. account := &Account{
  894. ID: 302, Name: "anthropic-real-cc", Platform: PlatformAnthropic, Type: AccountTypeOAuth, Concurrency: 1,
  895. Credentials: map[string]any{"access_token": "oauth-token"}, Status: StatusActive, Schedulable: true,
  896. }
  897. result, err := svc.Forward(context.Background(), c, account, parsed)
  898. require.NoError(t, err)
  899. require.NotNil(t, result)
  900. require.NotNil(t, upstream.lastReq)
  901. require.Equal(t, c.Request.Header.Get("User-Agent"), getHeaderRaw(upstream.lastReq.Header, "User-Agent"))
  902. require.Equal(t, "real-client-package", getHeaderRaw(upstream.lastReq.Header, "X-Stainless-Package-Version"))
  903. require.Equal(t, clientBeta, getHeaderRaw(upstream.lastReq.Header, "anthropic-beta"))
  904. require.Empty(t, getHeaderRaw(upstream.lastReq.Header, "x-client-request-id"), "真实 CC 不应被强制写入 mimic request id")
  905. require.Equal(t, gjson.GetBytes(body, "system").Raw, gjson.GetBytes(upstream.lastBody, "system").Raw)
  906. require.Equal(t, gjson.GetBytes(body, "messages").Raw, gjson.GetBytes(upstream.lastBody, "messages").Raw)
  907. require.Equal(t, metadataUserID, gjson.GetBytes(upstream.lastBody, "metadata.user_id").String())
  908. require.True(t, gjson.GetBytes(upstream.lastBody, "context_management").Exists())
  909. require.NotContains(t, string(upstream.lastBody), "x-anthropic-billing-header:")
  910. }
  911. func TestGatewayService_AnthropicOAuth_SystemPromptInjectionCanBeDisabled(t *testing.T) {
  912. gin.SetMode(gin.TestMode)
  913. resetGatewayForwardingSettingsCacheForTest(t)
  914. rec := httptest.NewRecorder()
  915. c, _ := gin.CreateTestContext(rec)
  916. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  917. body := []byte(`{"model":"claude-3-5-sonnet-latest","system":"Original system prompt","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  918. parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
  919. require.NoError(t, err)
  920. upstream := &anthropicHTTPUpstreamRecorder{
  921. resp: &http.Response{
  922. StatusCode: http.StatusOK,
  923. Header: http.Header{
  924. "Content-Type": []string{"application/json"},
  925. "x-request-id": []string{"rid-oauth-no-system-injection"},
  926. },
  927. Body: io.NopCloser(strings.NewReader(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet-20241022","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":12,"output_tokens":7}}`)),
  928. },
  929. }
  930. cfg := &config.Config{
  931. Gateway: config.GatewayConfig{
  932. MaxLineSize: defaultMaxLineSize,
  933. },
  934. }
  935. settingService := NewSettingService(&gatewayTTLSettingRepo{data: map[string]string{
  936. SettingKeyEnableClaudeOAuthSystemPromptInjection: "false",
  937. }}, cfg)
  938. svc := &GatewayService{
  939. cfg: cfg,
  940. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  941. httpUpstream: upstream,
  942. rateLimitService: &RateLimitService{},
  943. deferredService: &DeferredService{},
  944. settingService: settingService,
  945. }
  946. account := &Account{
  947. ID: 302,
  948. Name: "anthropic-oauth-no-system-injection",
  949. Platform: PlatformAnthropic,
  950. Type: AccountTypeOAuth,
  951. Concurrency: 1,
  952. Credentials: map[string]any{
  953. "access_token": "oauth-token",
  954. },
  955. Status: StatusActive,
  956. Schedulable: true,
  957. }
  958. result, err := svc.Forward(context.Background(), c, account, parsed)
  959. require.NoError(t, err)
  960. require.NotNil(t, result)
  961. system := gjson.GetBytes(upstream.lastBody, "system")
  962. require.True(t, system.Exists())
  963. require.Equal(t, "Original system prompt", system.String())
  964. require.NotContains(t, string(upstream.lastBody), "x-anthropic-billing-header:")
  965. require.NotContains(t, string(upstream.lastBody), "[System Instructions]")
  966. }
  967. func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingStillCollectsUsageAfterClientDisconnect(t *testing.T) {
  968. gin.SetMode(gin.TestMode)
  969. // Use a canceled context recorder to simulate client disconnect behavior.
  970. req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  971. ctx, cancel := context.WithCancel(req.Context())
  972. cancel()
  973. req = req.WithContext(ctx)
  974. rec := httptest.NewRecorder()
  975. c, _ := gin.CreateTestContext(rec)
  976. c.Request = req
  977. svc := &GatewayService{
  978. cfg: &config.Config{
  979. Gateway: config.GatewayConfig{
  980. MaxLineSize: defaultMaxLineSize,
  981. },
  982. },
  983. rateLimitService: &RateLimitService{},
  984. }
  985. resp := &http.Response{
  986. StatusCode: http.StatusOK,
  987. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  988. Body: io.NopCloser(strings.NewReader(strings.Join([]string{
  989. `data: {"type":"message_start","message":{"usage":{"input_tokens":11}}}`,
  990. "",
  991. `data: {"type":"message_delta","usage":{"output_tokens":5}}`,
  992. "",
  993. "data: [DONE]",
  994. "",
  995. }, "\n"))),
  996. }
  997. result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "claude-3-7-sonnet-20250219")
  998. require.NoError(t, err)
  999. require.NotNil(t, result)
  1000. require.NotNil(t, result.usage)
  1001. require.Equal(t, 11, result.usage.InputTokens)
  1002. require.Equal(t, 5, result.usage.OutputTokens)
  1003. }
  1004. func TestGatewayService_AnthropicAPIKeyPassthrough_MissingTerminalEventReturnsError(t *testing.T) {
  1005. gin.SetMode(gin.TestMode)
  1006. rec := httptest.NewRecorder()
  1007. c, _ := gin.CreateTestContext(rec)
  1008. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1009. svc := &GatewayService{
  1010. cfg: &config.Config{
  1011. Gateway: config.GatewayConfig{
  1012. MaxLineSize: defaultMaxLineSize,
  1013. },
  1014. },
  1015. rateLimitService: &RateLimitService{},
  1016. }
  1017. resp := &http.Response{
  1018. StatusCode: http.StatusOK,
  1019. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  1020. Body: io.NopCloser(strings.NewReader(strings.Join([]string{
  1021. `data: {"type":"message_start","message":{"usage":{"input_tokens":11}}}`,
  1022. "",
  1023. `data: {"type":"message_delta","usage":{"output_tokens":5}}`,
  1024. "",
  1025. }, "\n"))),
  1026. }
  1027. result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "claude-3-7-sonnet-20250219")
  1028. require.Error(t, err)
  1029. require.Contains(t, err.Error(), "missing terminal event")
  1030. require.NotNil(t, result)
  1031. }
  1032. func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_NonStreamingSuccess(t *testing.T) {
  1033. gin.SetMode(gin.TestMode)
  1034. rec := httptest.NewRecorder()
  1035. c, _ := gin.CreateTestContext(rec)
  1036. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1037. body := []byte(`{"model":"claude-3-5-sonnet-latest","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
  1038. upstreamJSON := `{"id":"msg_1","type":"message","usage":{"input_tokens":12,"output_tokens":7,"cache_creation":{"ephemeral_5m_input_tokens":2,"ephemeral_1h_input_tokens":3},"cached_tokens":4}}`
  1039. upstream := &anthropicHTTPUpstreamRecorder{
  1040. resp: &http.Response{
  1041. StatusCode: http.StatusOK,
  1042. Header: http.Header{
  1043. "Content-Type": []string{"application/json"},
  1044. "x-request-id": []string{"rid-nonstream"},
  1045. },
  1046. Body: io.NopCloser(strings.NewReader(upstreamJSON)),
  1047. },
  1048. }
  1049. svc := &GatewayService{
  1050. cfg: &config.Config{},
  1051. httpUpstream: upstream,
  1052. rateLimitService: &RateLimitService{},
  1053. }
  1054. result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, newAnthropicAPIKeyAccountForTest(), body, "claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest", false, time.Now())
  1055. require.NoError(t, err)
  1056. require.NotNil(t, result)
  1057. require.Equal(t, 12, result.Usage.InputTokens)
  1058. require.Equal(t, 7, result.Usage.OutputTokens)
  1059. require.Equal(t, 5, result.Usage.CacheCreationInputTokens)
  1060. require.Equal(t, 4, result.Usage.CacheReadInputTokens)
  1061. require.Equal(t, upstreamJSON, rec.Body.String())
  1062. }
  1063. func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_InvalidTokenType(t *testing.T) {
  1064. gin.SetMode(gin.TestMode)
  1065. rec := httptest.NewRecorder()
  1066. c, _ := gin.CreateTestContext(rec)
  1067. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1068. account := &Account{
  1069. ID: 202,
  1070. Name: "anthropic-oauth",
  1071. Platform: PlatformAnthropic,
  1072. Type: AccountTypeOAuth,
  1073. Credentials: map[string]any{
  1074. "access_token": "oauth-token",
  1075. },
  1076. }
  1077. svc := &GatewayService{}
  1078. result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, account, []byte(`{}`), "claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest", false, time.Now())
  1079. require.Nil(t, result)
  1080. require.Error(t, err)
  1081. require.Contains(t, err.Error(), "requires apikey token")
  1082. }
  1083. func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_UpstreamRequestError(t *testing.T) {
  1084. gin.SetMode(gin.TestMode)
  1085. rec := httptest.NewRecorder()
  1086. c, _ := gin.CreateTestContext(rec)
  1087. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1088. upstream := &anthropicHTTPUpstreamRecorder{
  1089. err: errors.New("dial tcp timeout"),
  1090. }
  1091. svc := &GatewayService{
  1092. cfg: &config.Config{
  1093. Security: config.SecurityConfig{
  1094. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  1095. },
  1096. },
  1097. httpUpstream: upstream,
  1098. }
  1099. account := newAnthropicAPIKeyAccountForTest()
  1100. result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, account, []byte(`{"model":"x"}`), "x", "x", false, time.Now())
  1101. require.Nil(t, result)
  1102. require.Error(t, err)
  1103. var failoverErr *UpstreamFailoverError
  1104. require.ErrorAs(t, err, &failoverErr)
  1105. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  1106. require.True(t, failoverErr.ShouldRetryNextAccount())
  1107. // 传输层错误交给 handler failover,service 不得写响应。
  1108. require.False(t, c.Writer.Written())
  1109. }
  1110. func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardDirect_EmptyResponseBody(t *testing.T) {
  1111. gin.SetMode(gin.TestMode)
  1112. rec := httptest.NewRecorder()
  1113. c, _ := gin.CreateTestContext(rec)
  1114. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1115. upstream := &anthropicHTTPUpstreamRecorder{
  1116. resp: &http.Response{
  1117. StatusCode: http.StatusOK,
  1118. Header: http.Header{"x-request-id": []string{"rid-empty-body"}},
  1119. Body: nil,
  1120. },
  1121. }
  1122. svc := &GatewayService{
  1123. cfg: &config.Config{
  1124. Security: config.SecurityConfig{
  1125. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  1126. },
  1127. },
  1128. httpUpstream: upstream,
  1129. }
  1130. result, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, newAnthropicAPIKeyAccountForTest(), []byte(`{"model":"x"}`), "x", "x", false, time.Now())
  1131. require.Nil(t, result)
  1132. require.Error(t, err)
  1133. require.Contains(t, err.Error(), "empty response")
  1134. }
  1135. func TestExtractAnthropicSSEDataLine(t *testing.T) {
  1136. t.Run("valid data line with spaces", func(t *testing.T) {
  1137. data, ok := extractAnthropicSSEDataLine("data: {\"type\":\"message_start\"}")
  1138. require.True(t, ok)
  1139. require.Equal(t, `{"type":"message_start"}`, data)
  1140. })
  1141. t.Run("non data line", func(t *testing.T) {
  1142. data, ok := extractAnthropicSSEDataLine("event: message_start")
  1143. require.False(t, ok)
  1144. require.Empty(t, data)
  1145. })
  1146. }
  1147. func TestGatewayService_ParseSSEUsagePassthrough_MessageStartFallbacks(t *testing.T) {
  1148. usage := &ClaudeUsage{}
  1149. data := `{"type":"message_start","message":{"usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cached_tokens":9,"cache_creation":{"ephemeral_5m_input_tokens":3,"ephemeral_1h_input_tokens":4}}}}`
  1150. parseSSEUsagePassthrough(data, usage)
  1151. require.Equal(t, 12, usage.InputTokens)
  1152. require.Equal(t, 9, usage.CacheReadInputTokens, "应兼容 cached_tokens 字段")
  1153. require.Equal(t, 7, usage.CacheCreationInputTokens, "聚合字段为空时应从 5m/1h 明细回填")
  1154. require.Equal(t, 3, usage.CacheCreation5mTokens)
  1155. require.Equal(t, 4, usage.CacheCreation1hTokens)
  1156. }
  1157. func TestGatewayService_ParseSSEUsagePassthrough_MessageDeltaSelectiveOverwrite(t *testing.T) {
  1158. usage := &ClaudeUsage{}
  1159. start := `{"type":"message_start","message":{"usage":{"input_tokens":10,"cache_creation_input_tokens":463184,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":463184}}}}`
  1160. parseSSEUsagePassthrough(start, usage)
  1161. data := `{"type":"message_delta","usage":{"input_tokens":0,"output_tokens":5,"cache_creation_input_tokens":463184,"cache_read_input_tokens":0,"cached_tokens":11,"cache_creation":{"ephemeral_5m_input_tokens":463184,"ephemeral_1h_input_tokens":0}}}`
  1162. parseSSEUsagePassthrough(data, usage)
  1163. require.Equal(t, 10, usage.InputTokens, "message_delta 中 0 值不应覆盖已有 input_tokens")
  1164. require.Equal(t, 5, usage.OutputTokens)
  1165. require.Equal(t, 463184, usage.CacheCreationInputTokens)
  1166. require.Equal(t, 11, usage.CacheReadInputTokens, "cache_read_input_tokens 为空时应回退到 cached_tokens")
  1167. require.Equal(t, 463184, usage.CacheCreation5mTokens)
  1168. require.Equal(t, 0, usage.CacheCreation1hTokens)
  1169. }
  1170. func TestGatewayService_ParseSSEUsagePassthrough_NoopCases(t *testing.T) {
  1171. usage := &ClaudeUsage{InputTokens: 3}
  1172. parseSSEUsagePassthrough("", usage)
  1173. require.Equal(t, 3, usage.InputTokens)
  1174. parseSSEUsagePassthrough("[DONE]", usage)
  1175. require.Equal(t, 3, usage.InputTokens)
  1176. parseSSEUsagePassthrough("not-json", usage)
  1177. require.Equal(t, 3, usage.InputTokens)
  1178. // nil usage 不应 panic
  1179. parseSSEUsagePassthrough(`{"type":"message_start"}`, nil)
  1180. }
  1181. func TestGatewayService_ParseSSEUsagePassthrough_FallbackFromUsageNode(t *testing.T) {
  1182. usage := &ClaudeUsage{}
  1183. data := `{"type":"content_block_delta","usage":{"cached_tokens":6,"cache_creation":{"ephemeral_5m_input_tokens":2,"ephemeral_1h_input_tokens":1}}}`
  1184. parseSSEUsagePassthrough(data, usage)
  1185. require.Equal(t, 6, usage.CacheReadInputTokens)
  1186. require.Equal(t, 3, usage.CacheCreationInputTokens)
  1187. }
  1188. func TestParseClaudeUsageFromResponseBody(t *testing.T) {
  1189. t.Run("empty or missing usage", func(t *testing.T) {
  1190. got := parseClaudeUsageFromResponseBody(nil)
  1191. require.NotNil(t, got)
  1192. require.Equal(t, 0, got.InputTokens)
  1193. got = parseClaudeUsageFromResponseBody([]byte(`{"id":"x"}`))
  1194. require.NotNil(t, got)
  1195. require.Equal(t, 0, got.OutputTokens)
  1196. })
  1197. t.Run("parse all usage fields and fallback", func(t *testing.T) {
  1198. body := []byte(`{"usage":{"input_tokens":21,"output_tokens":34,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cached_tokens":13,"cache_creation":{"ephemeral_5m_input_tokens":5,"ephemeral_1h_input_tokens":8}}}`)
  1199. got := parseClaudeUsageFromResponseBody(body)
  1200. require.Equal(t, 21, got.InputTokens)
  1201. require.Equal(t, 34, got.OutputTokens)
  1202. require.Equal(t, 13, got.CacheReadInputTokens, "cache_read_input_tokens 为空时应回退 cached_tokens")
  1203. require.Equal(t, 13, got.CacheCreationInputTokens, "聚合字段为空时应由 5m/1h 回填")
  1204. require.Equal(t, 5, got.CacheCreation5mTokens)
  1205. require.Equal(t, 8, got.CacheCreation1hTokens)
  1206. })
  1207. t.Run("keep explicit aggregate values", func(t *testing.T) {
  1208. body := []byte(`{"usage":{"input_tokens":1,"output_tokens":2,"cache_creation_input_tokens":9,"cache_read_input_tokens":7,"cached_tokens":99,"cache_creation":{"ephemeral_5m_input_tokens":4,"ephemeral_1h_input_tokens":5}}}`)
  1209. got := parseClaudeUsageFromResponseBody(body)
  1210. require.Equal(t, 9, got.CacheCreationInputTokens, "已显式提供聚合字段时不应被明细覆盖")
  1211. require.Equal(t, 7, got.CacheReadInputTokens, "已显式提供 cache_read_input_tokens 时不应回退 cached_tokens")
  1212. })
  1213. }
  1214. func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingErrTooLong(t *testing.T) {
  1215. gin.SetMode(gin.TestMode)
  1216. rec := httptest.NewRecorder()
  1217. c, _ := gin.CreateTestContext(rec)
  1218. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1219. svc := &GatewayService{
  1220. cfg: &config.Config{
  1221. Gateway: config.GatewayConfig{
  1222. MaxLineSize: 32,
  1223. },
  1224. },
  1225. }
  1226. // Scanner 初始缓冲为 64KB,构造更长单行触发 bufio.ErrTooLong。
  1227. longLine := "data: " + strings.Repeat("x", 80*1024)
  1228. resp := &http.Response{
  1229. StatusCode: http.StatusOK,
  1230. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  1231. Body: io.NopCloser(strings.NewReader(longLine)),
  1232. }
  1233. result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 2}, time.Now(), "claude-3-7-sonnet-20250219")
  1234. require.Error(t, err)
  1235. require.ErrorIs(t, err, bufio.ErrTooLong)
  1236. require.NotNil(t, result)
  1237. }
  1238. func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingDataIntervalTimeout(t *testing.T) {
  1239. gin.SetMode(gin.TestMode)
  1240. rec := httptest.NewRecorder()
  1241. c, _ := gin.CreateTestContext(rec)
  1242. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1243. svc := &GatewayService{
  1244. cfg: &config.Config{
  1245. Gateway: config.GatewayConfig{
  1246. StreamDataIntervalTimeout: 1,
  1247. MaxLineSize: defaultMaxLineSize,
  1248. },
  1249. },
  1250. rateLimitService: &RateLimitService{},
  1251. }
  1252. pr, pw := io.Pipe()
  1253. resp := &http.Response{
  1254. StatusCode: http.StatusOK,
  1255. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  1256. Body: pr,
  1257. }
  1258. result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 5}, time.Now(), "claude-3-7-sonnet-20250219")
  1259. _ = pw.Close()
  1260. _ = pr.Close()
  1261. require.Error(t, err)
  1262. require.Contains(t, err.Error(), "stream data interval timeout")
  1263. require.NotNil(t, result)
  1264. require.False(t, result.clientDisconnect)
  1265. }
  1266. func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingSendsKeepaliveDuringIdle(t *testing.T) {
  1267. gin.SetMode(gin.TestMode)
  1268. rec := httptest.NewRecorder()
  1269. c, _ := gin.CreateTestContext(rec)
  1270. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1271. svc := &GatewayService{
  1272. cfg: &config.Config{
  1273. Gateway: config.GatewayConfig{
  1274. StreamKeepaliveInterval: 1,
  1275. MaxLineSize: defaultMaxLineSize,
  1276. },
  1277. },
  1278. rateLimitService: &RateLimitService{},
  1279. }
  1280. pr, pw := io.Pipe()
  1281. resp := &http.Response{
  1282. StatusCode: http.StatusOK,
  1283. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  1284. Body: pr,
  1285. }
  1286. done := make(chan struct{})
  1287. go func() {
  1288. defer close(done)
  1289. time.Sleep(1200 * time.Millisecond)
  1290. _, _ = pw.Write([]byte(strings.Join([]string{
  1291. `data: {"type":"message_start","message":{"usage":{"input_tokens":3}}}`,
  1292. "",
  1293. `data: {"type":"message_delta","usage":{"output_tokens":2}}`,
  1294. "",
  1295. "data: [DONE]",
  1296. "",
  1297. }, "\n")))
  1298. _ = pw.Close()
  1299. }()
  1300. result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 8}, time.Now(), "claude-3-7-sonnet-20250219")
  1301. _ = pr.Close()
  1302. <-done
  1303. require.NoError(t, err)
  1304. require.NotNil(t, result)
  1305. require.Contains(t, rec.Body.String(), "event: ping\ndata: {\"type\": \"ping\"}\n\n")
  1306. require.Contains(t, rec.Body.String(), "data: [DONE]")
  1307. }
  1308. func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingKeepaliveDoesNotInterleavePartialEvent(t *testing.T) {
  1309. gin.SetMode(gin.TestMode)
  1310. rec := httptest.NewRecorder()
  1311. c, _ := gin.CreateTestContext(rec)
  1312. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1313. svc := &GatewayService{
  1314. cfg: &config.Config{
  1315. Gateway: config.GatewayConfig{
  1316. StreamKeepaliveInterval: 1,
  1317. MaxLineSize: defaultMaxLineSize,
  1318. },
  1319. },
  1320. rateLimitService: &RateLimitService{},
  1321. }
  1322. pr, pw := io.Pipe()
  1323. resp := &http.Response{
  1324. StatusCode: http.StatusOK,
  1325. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  1326. Body: pr,
  1327. }
  1328. done := make(chan struct{})
  1329. go func() {
  1330. defer close(done)
  1331. _, _ = pw.Write([]byte(`data: {"type":"message_start","message":{"usage":{"input_tokens":4}}}` + "\n"))
  1332. time.Sleep(1200 * time.Millisecond)
  1333. _, _ = pw.Write([]byte("\n"))
  1334. _, _ = pw.Write([]byte("data: [DONE]\n\n"))
  1335. _ = pw.Close()
  1336. }()
  1337. result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 9}, time.Now(), "claude-3-7-sonnet-20250219")
  1338. _ = pr.Close()
  1339. <-done
  1340. require.NoError(t, err)
  1341. require.NotNil(t, result)
  1342. body := rec.Body.String()
  1343. require.NotContains(t, body, `data: {"type":"message_start","message":{"usage":{"input_tokens":4}}}`+"\n"+"event: ping")
  1344. require.NotContains(t, body, "event: ping")
  1345. require.Contains(t, body, "data: [DONE]")
  1346. }
  1347. func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingReadError(t *testing.T) {
  1348. gin.SetMode(gin.TestMode)
  1349. rec := httptest.NewRecorder()
  1350. c, _ := gin.CreateTestContext(rec)
  1351. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1352. svc := &GatewayService{
  1353. cfg: &config.Config{
  1354. Gateway: config.GatewayConfig{
  1355. MaxLineSize: defaultMaxLineSize,
  1356. },
  1357. },
  1358. }
  1359. resp := &http.Response{
  1360. StatusCode: http.StatusOK,
  1361. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  1362. Body: &streamReadCloser{
  1363. err: io.ErrUnexpectedEOF,
  1364. },
  1365. }
  1366. result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 6}, time.Now(), "claude-3-7-sonnet-20250219")
  1367. require.Error(t, err)
  1368. require.Contains(t, err.Error(), "stream read error")
  1369. require.NotNil(t, result)
  1370. require.False(t, result.clientDisconnect)
  1371. }
  1372. func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingTimeoutAfterClientDisconnect(t *testing.T) {
  1373. gin.SetMode(gin.TestMode)
  1374. rec := httptest.NewRecorder()
  1375. c, _ := gin.CreateTestContext(rec)
  1376. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1377. c.Writer = &failWriteResponseWriter{ResponseWriter: c.Writer}
  1378. svc := &GatewayService{
  1379. cfg: &config.Config{
  1380. Gateway: config.GatewayConfig{
  1381. StreamDataIntervalTimeout: 1,
  1382. MaxLineSize: defaultMaxLineSize,
  1383. },
  1384. },
  1385. rateLimitService: &RateLimitService{},
  1386. }
  1387. pr, pw := io.Pipe()
  1388. resp := &http.Response{
  1389. StatusCode: http.StatusOK,
  1390. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  1391. Body: pr,
  1392. }
  1393. done := make(chan struct{})
  1394. go func() {
  1395. defer close(done)
  1396. _, _ = pw.Write([]byte(`data: {"type":"message_start","message":{"usage":{"input_tokens":9}}}` + "\n"))
  1397. // 保持上游连接静默,触发数据间隔超时分支。
  1398. time.Sleep(1500 * time.Millisecond)
  1399. _ = pw.Close()
  1400. }()
  1401. result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 7}, time.Now(), "claude-3-7-sonnet-20250219")
  1402. _ = pr.Close()
  1403. <-done
  1404. require.Error(t, err)
  1405. require.Contains(t, err.Error(), "stream usage incomplete after timeout")
  1406. require.NotNil(t, result)
  1407. require.True(t, result.clientDisconnect)
  1408. require.Equal(t, 9, result.usage.InputTokens)
  1409. }
  1410. func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingContextCanceled(t *testing.T) {
  1411. gin.SetMode(gin.TestMode)
  1412. rec := httptest.NewRecorder()
  1413. c, _ := gin.CreateTestContext(rec)
  1414. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1415. svc := &GatewayService{
  1416. cfg: &config.Config{
  1417. Gateway: config.GatewayConfig{
  1418. MaxLineSize: defaultMaxLineSize,
  1419. },
  1420. },
  1421. }
  1422. resp := &http.Response{
  1423. StatusCode: http.StatusOK,
  1424. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  1425. Body: &streamReadCloser{
  1426. err: context.Canceled,
  1427. },
  1428. }
  1429. result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 3}, time.Now(), "claude-3-7-sonnet-20250219")
  1430. require.Error(t, err)
  1431. require.Contains(t, err.Error(), "stream usage incomplete")
  1432. require.NotNil(t, result)
  1433. require.True(t, result.clientDisconnect)
  1434. }
  1435. func TestGatewayService_AnthropicAPIKeyPassthrough_StreamingUpstreamReadErrorAfterClientDisconnect(t *testing.T) {
  1436. gin.SetMode(gin.TestMode)
  1437. rec := httptest.NewRecorder()
  1438. c, _ := gin.CreateTestContext(rec)
  1439. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1440. c.Writer = &failWriteResponseWriter{ResponseWriter: c.Writer}
  1441. svc := &GatewayService{
  1442. cfg: &config.Config{
  1443. Gateway: config.GatewayConfig{
  1444. MaxLineSize: defaultMaxLineSize,
  1445. },
  1446. },
  1447. }
  1448. resp := &http.Response{
  1449. StatusCode: http.StatusOK,
  1450. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  1451. Body: &streamReadCloser{
  1452. payload: []byte(`data: {"type":"message_start","message":{"usage":{"input_tokens":8}}}` + "\n\n"),
  1453. err: io.ErrUnexpectedEOF,
  1454. },
  1455. }
  1456. result, err := svc.handleStreamingResponseAnthropicAPIKeyPassthrough(context.Background(), resp, c, &Account{ID: 4}, time.Now(), "claude-3-7-sonnet-20250219")
  1457. require.Error(t, err)
  1458. require.Contains(t, err.Error(), "stream usage incomplete after disconnect")
  1459. require.NotNil(t, result)
  1460. require.True(t, result.clientDisconnect)
  1461. require.Equal(t, 8, result.usage.InputTokens)
  1462. }
  1463. func TestGatewayService_AnthropicAPIKeyPassthrough_TransportErrorRecordsOllamaActivity(t *testing.T) {
  1464. gin.SetMode(gin.TestMode)
  1465. deferred := NewDeferredService(nil, nil, time.Second)
  1466. upstream := &anthropicHTTPUpstreamRecorder{err: errors.New("dial tcp timeout")}
  1467. svc := &GatewayService{
  1468. cfg: &config.Config{
  1469. Security: config.SecurityConfig{
  1470. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  1471. },
  1472. },
  1473. httpUpstream: upstream,
  1474. deferredService: deferred,
  1475. }
  1476. ollama := &Account{
  1477. ID: 601, Name: "ollama-anthropic", Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
  1478. Concurrency: 1,
  1479. Credentials: map[string]any{"api_key": "k-ollama", "base_url": "https://ollama.com"},
  1480. Extra: map[string]any{"anthropic_passthrough": true},
  1481. Status: StatusActive, Schedulable: true,
  1482. }
  1483. other := newAnthropicAPIKeyAccountForTest()
  1484. other.ID = 602
  1485. rec := httptest.NewRecorder()
  1486. c, _ := gin.CreateTestContext(rec)
  1487. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1488. _, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, ollama, []byte(`{"model":"x"}`), "x", "x", false, time.Now())
  1489. require.Error(t, err)
  1490. rec2 := httptest.NewRecorder()
  1491. c2, _ := gin.CreateTestContext(rec2)
  1492. c2.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1493. _, err = svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c2, other, []byte(`{"model":"x"}`), "x", "x", false, time.Now())
  1494. require.Error(t, err)
  1495. _, ok := deferred.lastUsedUpdates.Load(int64(601))
  1496. require.True(t, ok, "Anthropic passthrough transport error on Ollama account must record activity")
  1497. _, ok = deferred.lastUsedUpdates.Load(int64(602))
  1498. require.False(t, ok, "non-Ollama Anthropic passthrough transport error must not record Ollama activity")
  1499. }
  1500. func TestGatewayService_AnthropicAPIKeyPassthrough_ContextCanceledSkipsOllamaActivity(t *testing.T) {
  1501. gin.SetMode(gin.TestMode)
  1502. deferred := NewDeferredService(nil, nil, time.Second)
  1503. upstream := &anthropicHTTPUpstreamRecorder{err: context.Canceled}
  1504. svc := &GatewayService{
  1505. cfg: &config.Config{
  1506. Security: config.SecurityConfig{
  1507. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  1508. },
  1509. },
  1510. httpUpstream: upstream,
  1511. deferredService: deferred,
  1512. }
  1513. ollama := &Account{
  1514. ID: 603, Name: "ollama-canceled", Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
  1515. Concurrency: 1,
  1516. Credentials: map[string]any{"api_key": "k-ollama", "base_url": "https://ollama.com"},
  1517. Extra: map[string]any{"anthropic_passthrough": true},
  1518. Status: StatusActive, Schedulable: true,
  1519. }
  1520. rec := httptest.NewRecorder()
  1521. c, _ := gin.CreateTestContext(rec)
  1522. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1523. _, err := svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, ollama, []byte(`{"model":"x"}`), "x", "x", false, time.Now())
  1524. require.Error(t, err)
  1525. _, ok := deferred.lastUsedUpdates.Load(int64(603))
  1526. require.False(t, ok, "context.Canceled on Anthropic passthrough must not count as Ollama activity")
  1527. }
  1528. func TestGatewayService_AnthropicAPIKeyPassthrough_Non2xxRecordsOllamaActivity(t *testing.T) {
  1529. gin.SetMode(gin.TestMode)
  1530. deferred := NewDeferredService(nil, nil, time.Second)
  1531. // 400 is non-retryable / non-failover for default API-key accounts, so it reaches handleErrorResponse.
  1532. upstream := &anthropicHTTPUpstreamRecorder{
  1533. resp: &http.Response{
  1534. StatusCode: http.StatusBadRequest,
  1535. Header: http.Header{"Content-Type": []string{"application/json"}},
  1536. Body: io.NopCloser(strings.NewReader(`{"type":"error","error":{"type":"invalid_request_error","message":"bad"}}`)),
  1537. },
  1538. }
  1539. svc := &GatewayService{
  1540. cfg: &config.Config{
  1541. Security: config.SecurityConfig{
  1542. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  1543. },
  1544. },
  1545. httpUpstream: upstream,
  1546. deferredService: deferred,
  1547. rateLimitService: &RateLimitService{},
  1548. }
  1549. ollama := &Account{
  1550. ID: 604, Name: "ollama-400", Platform: PlatformAnthropic, Type: AccountTypeAPIKey,
  1551. Concurrency: 1,
  1552. Credentials: map[string]any{"api_key": "k-ollama", "base_url": "https://ollama.com"},
  1553. Extra: map[string]any{"anthropic_passthrough": true},
  1554. Status: StatusActive, Schedulable: true,
  1555. }
  1556. rec := httptest.NewRecorder()
  1557. c, _ := gin.CreateTestContext(rec)
  1558. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1559. _, _ = svc.forwardAnthropicAPIKeyPassthrough(context.Background(), c, ollama, []byte(`{"model":"x"}`), "x", "x", false, time.Now())
  1560. _, ok := deferred.lastUsedUpdates.Load(int64(604))
  1561. require.True(t, ok, "Anthropic passthrough non-2xx on Ollama account must record activity via handleErrorResponse")
  1562. }