anthropic_apikey_auth_ollama_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. package service
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "testing"
  7. "github.com/Wei-Shaw/sub2api/internal/config"
  8. "github.com/gin-gonic/gin"
  9. "github.com/stretchr/testify/require"
  10. )
  11. // newOllamaCloudAnthropicAuthAccount 构造挂在 Anthropic 平台分组下的 Ollama Cloud
  12. // API Key 账号(base_url 指向官方 ollama.com Anthropic 兼容端点)。
  13. func newOllamaCloudAnthropicAuthAccount(baseURL string, extra map[string]any) *Account {
  14. if extra == nil {
  15. extra = map[string]any{}
  16. }
  17. return &Account{
  18. ID: 901,
  19. Name: "ollama-cloud-anthropic-auth",
  20. Platform: PlatformAnthropic,
  21. Type: AccountTypeAPIKey,
  22. Concurrency: 1,
  23. Credentials: map[string]any{
  24. "api_key": "ollama-cloud-key",
  25. "base_url": baseURL,
  26. },
  27. Extra: extra,
  28. Status: StatusActive,
  29. Schedulable: true,
  30. }
  31. }
  32. // TestSetAnthropicAPIKeyAuthHeader_OllamaCloudForcesBearer:Ollama Cloud 官方
  33. // Anthropic 兼容端点只认 Authorization: Bearer——extra 缺失或显式 x_api_key 均
  34. // 强制 Bearer;判定与实际 base_url 同源并做与出站组装一致的尾斜杠归一化;
  35. // 其它上游保持历史 extra/default 行为,恶意相似 host 不误判。
  36. func TestSetAnthropicAPIKeyAuthHeader_OllamaCloudForcesBearer(t *testing.T) {
  37. tests := []struct {
  38. name string
  39. baseURL string
  40. extra map[string]any
  41. accountType string
  42. wantAuth string // "" = 不应出现 Authorization 头
  43. wantXAPIKey string // "" = 不应出现 x-api-key 头
  44. }{
  45. {
  46. name: "ollama 缺 extra 默认 Bearer",
  47. baseURL: "https://ollama.com",
  48. wantAuth: "Bearer ollama-cloud-key",
  49. },
  50. {
  51. name: "ollama base 尾斜杠仍 Bearer",
  52. baseURL: "https://ollama.com/",
  53. wantAuth: "Bearer ollama-cloud-key",
  54. },
  55. {
  56. name: "ollama /v1 路径 Bearer",
  57. baseURL: "https://ollama.com/v1",
  58. wantAuth: "Bearer ollama-cloud-key",
  59. },
  60. {
  61. name: "ollama /v1 尾斜杠 Bearer",
  62. baseURL: "https://www.ollama.com/v1/",
  63. wantAuth: "Bearer ollama-cloud-key",
  64. },
  65. {
  66. name: "host 大小写不敏感 Bearer",
  67. baseURL: "https://OLLAMA.COM",
  68. wantAuth: "Bearer ollama-cloud-key",
  69. },
  70. {
  71. name: "ollama 显式 x_api_key 也强制 Bearer",
  72. baseURL: "https://ollama.com",
  73. extra: map[string]any{anthropicAPIKeyAuthSchemeExtraKey: AnthropicAPIKeyAuthSchemeXAPIKey},
  74. wantAuth: "Bearer ollama-cloud-key",
  75. },
  76. {
  77. name: "非 Ollama 默认 x-api-key",
  78. baseURL: "https://api.anthropic.com",
  79. wantXAPIKey: "ollama-cloud-key",
  80. },
  81. {
  82. name: "默认官方端点(空 base_url)x-api-key",
  83. baseURL: "",
  84. wantXAPIKey: "ollama-cloud-key",
  85. },
  86. {
  87. name: "非 Ollama 显式 authorization_bearer 保持 Bearer",
  88. baseURL: "https://api.anthropic.com",
  89. extra: map[string]any{anthropicAPIKeyAuthSchemeExtraKey: AnthropicAPIKeyAuthSchemeAuthorizationBearer},
  90. wantAuth: "Bearer ollama-cloud-key",
  91. },
  92. {
  93. name: "恶意相似 host 子串不命中",
  94. baseURL: "https://evil.com/ollama.com",
  95. wantXAPIKey: "ollama-cloud-key",
  96. },
  97. {
  98. name: "恶意相似 host 后缀不命中",
  99. baseURL: "https://ollama.com.evil.com",
  100. wantXAPIKey: "ollama-cloud-key",
  101. },
  102. {
  103. name: "http 明文不命中",
  104. baseURL: "http://ollama.com",
  105. wantXAPIKey: "ollama-cloud-key",
  106. },
  107. {
  108. name: "非标准端口不命中",
  109. baseURL: "https://ollama.com:8443",
  110. wantXAPIKey: "ollama-cloud-key",
  111. },
  112. {
  113. name: "带 path 的 messages 地址不命中(判定用 base 而非完整 URL)",
  114. baseURL: "https://ollama.com/v1/messages",
  115. wantXAPIKey: "ollama-cloud-key",
  116. },
  117. {
  118. name: "非 APIKey 账号类型不强制",
  119. baseURL: "https://ollama.com",
  120. accountType: AccountTypeOAuth,
  121. wantXAPIKey: "ollama-cloud-key",
  122. },
  123. }
  124. for _, tt := range tests {
  125. t.Run(tt.name, func(t *testing.T) {
  126. accountType := AccountTypeAPIKey
  127. if tt.accountType != "" {
  128. accountType = tt.accountType
  129. }
  130. account := newOllamaCloudAnthropicAuthAccount(tt.baseURL, tt.extra)
  131. account.Type = accountType
  132. header := http.Header{}
  133. setAnthropicAPIKeyAuthHeader(header, account, "ollama-cloud-key", tt.baseURL)
  134. require.Equal(t, tt.wantAuth, header.Get("Authorization"), "Authorization 头不符")
  135. require.Equal(t, tt.wantXAPIKey, header.Get("x-api-key"), "x-api-key 头不符")
  136. })
  137. }
  138. }
  139. // TestSetAnthropicAPIKeyAuthHeader_CNAdaptiveBaseURLResolution:adaptive 账号经
  140. // GetCNProtocolBaseURL(anthropic) / GetAnthropicProtocolBaseURL 选出的分协议地址
  141. // 才是判定依据,Chat Completions 地址(GetOpenAIBaseURL 选到的 CC base)不参与。
  142. func TestSetAnthropicAPIKeyAuthHeader_CNAdaptiveBaseURL(t *testing.T) {
  143. // adaptive:anthropic 分协议地址指向 ollama.com → Bearer;CC 地址同挂 ollama
  144. // 不影响(判定只用 anthropic 分协议地址)。
  145. adaptiveOllamaAnthropic := &Account{
  146. ID: 902,
  147. Name: "adaptive-ollama-anthropic",
  148. Platform: PlatformKimi,
  149. Type: AccountTypeAPIKey,
  150. Credentials: map[string]any{
  151. "api_key": "kimi-key",
  152. "api_protocol": APIProtocolAdaptive,
  153. "api_base_urls": map[string]any{
  154. APIProtocolAnthropic: "https://ollama.com",
  155. APIProtocolChatCompletions: "https://api.moonshot.cn",
  156. },
  157. },
  158. }
  159. anthropicBase := adaptiveOllamaAnthropic.GetCNProtocolBaseURL(APIProtocolAnthropic)
  160. require.Equal(t, "https://ollama.com", anthropicBase)
  161. header := http.Header{}
  162. setAnthropicAPIKeyAuthHeader(header, adaptiveOllamaAnthropic, "kimi-key", anthropicBase)
  163. require.Equal(t, "Bearer kimi-key", header.Get("Authorization"))
  164. require.Empty(t, header.Get("x-api-key"))
  165. // 反向:CC 地址挂 ollama.com,anthropic 分协议地址非 ollama → 不误判,
  166. // 保持 x-api-key。
  167. adaptiveOllamaCC := &Account{
  168. ID: 903,
  169. Name: "adaptive-ollama-cc",
  170. Platform: PlatformKimi,
  171. Type: AccountTypeAPIKey,
  172. Credentials: map[string]any{
  173. "api_key": "kimi-key",
  174. "api_protocol": APIProtocolAdaptive,
  175. "api_base_urls": map[string]any{
  176. APIProtocolAnthropic: "https://api.moonshot.cn/anthropic",
  177. APIProtocolChatCompletions: "https://ollama.com/v1",
  178. },
  179. },
  180. }
  181. anthropicBase = adaptiveOllamaCC.GetCNProtocolBaseURL(APIProtocolAnthropic)
  182. require.Equal(t, "https://api.moonshot.cn/anthropic", anthropicBase)
  183. header = http.Header{}
  184. setAnthropicAPIKeyAuthHeader(header, adaptiveOllamaCC, "kimi-key", anthropicBase)
  185. require.Empty(t, header.Get("Authorization"))
  186. require.Equal(t, "kimi-key", header.Get("x-api-key"))
  187. // anthropic 协议(非 adaptive):凭证 base_url 指向 ollama.com → Bearer。
  188. anthropicProtocolOllama := &Account{
  189. ID: 904,
  190. Name: "anthropic-protocol-ollama",
  191. Platform: PlatformKimi,
  192. Type: AccountTypeAPIKey,
  193. Credentials: map[string]any{
  194. "api_key": "kimi-key",
  195. "api_protocol": APIProtocolAnthropic,
  196. "base_url": "https://ollama.com",
  197. },
  198. }
  199. anthropicBase = anthropicProtocolOllama.GetAnthropicProtocolBaseURL()
  200. require.Equal(t, "https://ollama.com", anthropicBase)
  201. header = http.Header{}
  202. setAnthropicAPIKeyAuthHeader(header, anthropicProtocolOllama, "kimi-key", anthropicBase)
  203. require.Equal(t, "Bearer kimi-key", header.Get("Authorization"))
  204. require.Empty(t, header.Get("x-api-key"))
  205. // 同协议但 base 为 kimi 官方 → 保持 x-api-key。
  206. anthropicProtocolKimi := &Account{
  207. ID: 905,
  208. Name: "anthropic-protocol-kimi",
  209. Platform: PlatformKimi,
  210. Type: AccountTypeAPIKey,
  211. Credentials: map[string]any{
  212. "api_key": "kimi-key",
  213. "api_protocol": APIProtocolAnthropic,
  214. "base_url": "https://api.moonshot.cn/anthropic",
  215. },
  216. }
  217. header = http.Header{}
  218. setAnthropicAPIKeyAuthHeader(header, anthropicProtocolKimi, "kimi-key", anthropicProtocolKimi.GetAnthropicProtocolBaseURL())
  219. require.Empty(t, header.Get("Authorization"))
  220. require.Equal(t, "kimi-key", header.Get("x-api-key"))
  221. }
  222. // TestGatewayService_AnthropicPassthrough_OllamaCloudBearer:经 passthrough
  223. // builder 真实构造 http.Request 验证最终 header——Ollama Cloud 强制 Bearer 且
  224. // 不泄漏客户端入站认证;非 Ollama 上游保持 x-api-key。
  225. func TestGatewayService_AnthropicPassthrough_OllamaCloudBearer(t *testing.T) {
  226. gin.SetMode(gin.TestMode)
  227. buildReq := func(t *testing.T, baseURL string) *http.Request {
  228. t.Helper()
  229. rec := httptest.NewRecorder()
  230. c, _ := gin.CreateTestContext(rec)
  231. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  232. // 入站认证残留:正确上游只发一种认证头,不得泄漏客户端凭证
  233. c.Request.Header.Set("Authorization", "Bearer inbound-token")
  234. c.Request.Header.Set("X-Api-Key", "inbound-api-key")
  235. svc := &GatewayService{cfg: &config.Config{}}
  236. account := newOllamaCloudAnthropicAuthAccount(baseURL, nil)
  237. body := []byte(`{"model":"claude-3-7-sonnet-20250219","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
  238. req, _, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "ollama-cloud-key")
  239. require.NoError(t, err)
  240. return req
  241. }
  242. ollamaReq := buildReq(t, "https://ollama.com/")
  243. require.Equal(t, "Bearer ollama-cloud-key", ollamaReq.Header.Get("Authorization"))
  244. require.Empty(t, ollamaReq.Header.Get("x-api-key"), "Ollama Cloud 上游不得再发 x-api-key")
  245. require.NotContains(t, ollamaReq.Header.Get("Authorization"), "inbound-token")
  246. require.Empty(t, ollamaReq.Header.Get("x-inbound"), "sanity")
  247. anthropicReq := buildReq(t, "https://api.anthropic.com")
  248. require.Empty(t, anthropicReq.Header.Get("Authorization"))
  249. require.Equal(t, "ollama-cloud-key", anthropicReq.Header.Get("x-api-key"))
  250. require.NotContains(t, anthropicReq.Header.Get("x-api-key"), "inbound-api-key")
  251. }
  252. // TestGatewayService_BuildUpstreamRequest_OllamaCloudBearer:经 Anthropic 原生
  253. // GetBaseURL builder 真实构造 http.Request 验证最终 header。
  254. func TestGatewayService_BuildUpstreamRequest_OllamaCloudBearer(t *testing.T) {
  255. gin.SetMode(gin.TestMode)
  256. buildReq := func(t *testing.T, baseURL string) *http.Request {
  257. t.Helper()
  258. rec := httptest.NewRecorder()
  259. c, _ := gin.CreateTestContext(rec)
  260. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  261. c.Request.Header.Set("Authorization", "Bearer inbound-token")
  262. c.Request.Header.Set("X-Api-Key", "inbound-api-key")
  263. svc := &GatewayService{cfg: &config.Config{}}
  264. account := newOllamaCloudAnthropicAuthAccount(baseURL, nil)
  265. body := []byte(`{"model":"claude-3-7-sonnet-20250219","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
  266. req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", "apikey", "claude-3-7-sonnet-20250219", false, false)
  267. require.NoError(t, err)
  268. return req
  269. }
  270. ollamaReq := buildReq(t, "https://ollama.com")
  271. require.Equal(t, "https://ollama.com/v1/messages?beta=true", ollamaReq.URL.String())
  272. require.Equal(t, "Bearer ollama-cloud-key", ollamaReq.Header.Get("Authorization"))
  273. require.Empty(t, ollamaReq.Header.Get("x-api-key"))
  274. anthropicReq := buildReq(t, "https://api.anthropic.com")
  275. require.Empty(t, anthropicReq.Header.Get("Authorization"))
  276. require.Equal(t, "ollama-cloud-key", anthropicReq.Header.Get("x-api-key"))
  277. }
  278. // TestOpenAIGatewayService_NativeAnthropicBridge_OllamaCloudBearer:经 native
  279. // Anthropic 桥接 builder(CC/Responses → /v1/messages)真实构造 http.Request
  280. // 验证最终 header。
  281. func TestOpenAIGatewayService_NativeAnthropicBridge_OllamaCloudBearer(t *testing.T) {
  282. gin.SetMode(gin.TestMode)
  283. svc := &OpenAIGatewayService{cfg: &config.Config{}}
  284. account := &Account{
  285. ID: 906,
  286. Name: "anthropic-protocol-ollama-bridge",
  287. Platform: PlatformKimi,
  288. Type: AccountTypeAPIKey,
  289. Credentials: map[string]any{
  290. "api_key": "ollama-cloud-key",
  291. "api_protocol": APIProtocolAnthropic,
  292. "base_url": "https://ollama.com",
  293. },
  294. }
  295. targetURL, err := svc.nativeAnthropicTargetURL(account)
  296. require.NoError(t, err)
  297. require.Equal(t, "https://ollama.com/v1/messages", targetURL)
  298. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  299. body := []byte(`{"model":"kimi-k2-thinking","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
  300. req, _, err := svc.buildNativeAnthropicUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", targetURL)
  301. require.NoError(t, err)
  302. require.Equal(t, "Bearer ollama-cloud-key", req.Header.Get("Authorization"))
  303. require.Empty(t, req.Header.Get("x-api-key"))
  304. // 非 Ollama 上游:保持 x-api-key,无 Authorization。
  305. account.Credentials["base_url"] = "https://api.moonshot.cn/anthropic"
  306. targetURL, err = svc.nativeAnthropicTargetURL(account)
  307. require.NoError(t, err)
  308. req, _, err = svc.buildNativeAnthropicUpstreamRequest(context.Background(), c, account, body, "ollama-cloud-key", targetURL)
  309. require.NoError(t, err)
  310. require.Empty(t, req.Header.Get("Authorization"))
  311. require.Equal(t, "ollama-cloud-key", req.Header.Get("x-api-key"))
  312. }