cn_providers_test.go 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "testing"
  6. "time"
  7. "github.com/stretchr/testify/require"
  8. "github.com/tidwall/gjson"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. )
  11. // TestCNExtraKey 验证 provider 维度的 Extra 快照键由前缀 + 后缀拼接。
  12. func TestCNExtraKey(t *testing.T) {
  13. t.Parallel()
  14. require.Equal(t, "kimi_5h_used_percent", cnExtraKey(PlatformKimi, cnExtraSuffix5hUsed))
  15. require.Equal(t, "zhipu_weekly_reset_at", cnExtraKey(PlatformZhipu, cnExtraSuffixWeeklyReset))
  16. require.Equal(t, "deepseek_balance", cnExtraKey(PlatformDeepseek, cnBalanceExtraSuffixBalance))
  17. }
  18. // TestCNParseF64 兼容 JSON 数值与字符串(cc-switch 与上游字段类型不一致)。
  19. func TestCNParseF64(t *testing.T) {
  20. t.Parallel()
  21. cases := []struct {
  22. name string
  23. raw any
  24. want float64
  25. ok bool
  26. }{
  27. {"float64", float64(12.5), 12.5, true},
  28. {"int", 100, 100, true},
  29. {"numeric string", "33.3", 33.3, true},
  30. {"trim string", " 7 ", 7, true},
  31. {"non-numeric string", "abc", 0, false},
  32. {"nil", nil, 0, false},
  33. }
  34. for _, tc := range cases {
  35. tc := tc
  36. t.Run(tc.name, func(t *testing.T) {
  37. t.Parallel()
  38. got, ok := cnParseF64(tc.raw)
  39. require.Equal(t, tc.ok, ok)
  40. if ok {
  41. require.InDelta(t, tc.want, got, 1e-9)
  42. }
  43. })
  44. }
  45. }
  46. // TestCNMillisToRFC3339 秒级(<1e12)按秒、毫秒级按毫秒处理;非正返回空串。
  47. func TestCNMillisToRFC3339(t *testing.T) {
  48. t.Parallel()
  49. // 1700000000 秒 = 1700000000000 毫秒
  50. want := time.UnixMilli(1700000000000).UTC().Format(time.RFC3339)
  51. require.Equal(t, want, cnMillisToRFC3339(1700000000)) // 秒级
  52. require.Equal(t, want, cnMillisToRFC3339(1700000000000)) // 毫秒级
  53. require.Equal(t, "", cnMillisToRFC3339(0)) // 非正
  54. require.Equal(t, "", cnMillisToRFC3339(-1))
  55. }
  56. // TestCnnormalizeResetTime 覆盖 ISO8601 字符串 / 数字(秒、毫秒)/ 非法输入。
  57. func TestCnnormalizeResetTime(t *testing.T) {
  58. t.Parallel()
  59. // ISO8601 字符串归一化为 RFC3339(UTC)。
  60. require.Equal(t, "2026-08-14T10:00:00Z", cnNormalizeResetTime("2026-08-14T10:00:00Z"))
  61. // 毫秒级 float64。
  62. require.Equal(t,
  63. time.UnixMilli(1700000000000).UTC().Format(time.RFC3339),
  64. cnNormalizeResetTime(float64(1700000000000)))
  65. // 非法字符串。
  66. require.Equal(t, "", cnNormalizeResetTime("not-a-time"))
  67. require.Equal(t, "", cnNormalizeResetTime(""))
  68. }
  69. // TestParseKimiUsageTiers 验证 Kimi For Coding /usages 解析:
  70. // - 首个 limits[].detail → 5h 桶,utilization=(limit-remaining)/limit*100
  71. // - usage → weekly 桶
  72. // - 仅取首个 detail(多个 detail 时不应重复产出 5h)
  73. func TestParseKimiUsageTiers(t *testing.T) {
  74. t.Parallel()
  75. body := []byte(`{
  76. "limits": [
  77. {"name": "5h", "detail": {"limit": 1000, "remaining": 600, "resetTime": "2026-08-14T15:00:00Z"}},
  78. {"name": "ignored-second-detail", "detail": {"limit": 999, "remaining": 0, "resetTime": "2026-08-14T20:00:00Z"}}
  79. ],
  80. "usage": {"limit": 10000, "remaining": 4000, "resetTime": "2026-08-18T00:00:00Z"}
  81. }`)
  82. tiers := parseKimiUsageTiers(body)
  83. require.Len(t, tiers, 2)
  84. require.Equal(t, "5h", tiers[0].Window)
  85. require.InDelta(t, 40.0, tiers[0].UsedPercent, 1e-9) // (1000-600)/1000*100
  86. require.Equal(t, "2026-08-14T15:00:00Z", tiers[0].ResetAt)
  87. require.Equal(t, "weekly", tiers[1].Window)
  88. require.InDelta(t, 60.0, tiers[1].UsedPercent, 1e-9) // (10000-4000)/10000*100
  89. require.Equal(t, "2026-08-18T00:00:00Z", tiers[1].ResetAt)
  90. }
  91. // TestParseKimiUsageTiers_LimitZero 不应除零:limit=0 → utilization=0。
  92. func TestParseKimiUsageTiers_LimitZero(t *testing.T) {
  93. t.Parallel()
  94. body := []byte(`{"limits":[{"detail":{"limit":0,"remaining":0,"resetTime":"2026-08-14T15:00:00Z"}}]}`)
  95. tiers := parseKimiUsageTiers(body)
  96. require.Len(t, tiers, 1)
  97. require.InDelta(t, 0.0, tiers[0].UsedPercent, 1e-9)
  98. }
  99. // TestParseZhipuTokenTiers_UnitClassification 显式 unit(3=5h / 6=weekly)优先分类,
  100. // 不能被 reset 时间排序覆盖(周期末尾周窗口会更早重置)。
  101. func TestParseZhipuTokenTiers_UnitClassification(t *testing.T) {
  102. t.Parallel()
  103. // weekly 的 nextResetTime 早于 5h(模拟周期末尾),但 unit 必须胜出。
  104. data := gjson.Parse(`{
  105. "limits": [
  106. {"type":"TOKENS_LIMIT","unit":6,"percentage":70,"nextResetTime":1700000000000},
  107. {"type":"TOKENS_LIMIT","unit":3,"percentage":20,"nextResetTime":1700000099999}
  108. ]
  109. }`)
  110. tiers := parseZhipuTokenTiers(data)
  111. require.Len(t, tiers, 2)
  112. require.Equal(t, "5h", tiers[0].Window)
  113. require.InDelta(t, 20.0, tiers[0].UsedPercent, 1e-9)
  114. require.Equal(t, "weekly", tiers[1].Window)
  115. require.InDelta(t, 70.0, tiers[1].UsedPercent, 1e-9)
  116. }
  117. // TestParseZhipuTokenTiers_SingleTierOldPlan 老套餐仅回 1 条 → 降级为仅 5h。
  118. func TestParseZhipuTokenTiers_SingleTierOldPlan(t *testing.T) {
  119. t.Parallel()
  120. data := gjson.Parse(`{"limits":[{"type":"TOKENS_LIMIT","unit":3,"percentage":15,"nextResetTime":1700000000000}]}`)
  121. tiers := parseZhipuTokenTiers(data)
  122. require.Len(t, tiers, 1)
  123. require.Equal(t, "5h", tiers[0].Window)
  124. }
  125. // TestParseZhipuTokenTiers_FallbackHeuristic unit 缺失时:无 reset 的条目优先归 5h,
  126. // 其余按 reset 升序填入剩余槽位。
  127. func TestParseZhipuTokenTiers_FallbackHeuristic(t *testing.T) {
  128. t.Parallel()
  129. // 无 unit:A 无 reset、B 有 reset。A 先填 5h,B 填 weekly。
  130. data := gjson.Parse(`{
  131. "limits": [
  132. {"type":"TOKENS_LIMIT","percentage":50,"nextResetTime":1700000000000},
  133. {"type":"TOKENS_LIMIT","percentage":10}
  134. ]
  135. }`)
  136. tiers := parseZhipuTokenTiers(data)
  137. require.Len(t, tiers, 2)
  138. require.Equal(t, "5h", tiers[0].Window)
  139. require.InDelta(t, 10.0, tiers[0].UsedPercent, 1e-9) // 无 reset 优先 5h
  140. require.Equal(t, "weekly", tiers[1].Window)
  141. require.InDelta(t, 50.0, tiers[1].UsedPercent, 1e-9)
  142. }
  143. // TestParseZhipuTokenTiers_IgnoresNonTokenEntries 非 TOKENS_LIMIT/CREDIT_LIMIT 条目跳过。
  144. func TestParseZhipuTokenTiers_IgnoresNonTokenEntries(t *testing.T) {
  145. t.Parallel()
  146. data := gjson.Parse(`{"limits":[{"type":"OTHER_LIMIT","unit":3,"percentage":99}]}`)
  147. require.Empty(t, parseZhipuTokenTiers(data))
  148. }
  149. // TestCNQuotaExtraUpdates 验证 tier 列表落 Extra 快照键的 provider 前缀与窗口映射。
  150. func TestCNQuotaExtraUpdates(t *testing.T) {
  151. t.Parallel()
  152. now := time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC)
  153. tiers := []CNQuotaTier{
  154. {Window: "5h", UsedPercent: 40, ResetAt: "2026-08-14T15:00:00Z"},
  155. {Window: "weekly", UsedPercent: 60, ResetAt: "2026-08-18T00:00:00Z"},
  156. }
  157. updates := cnQuotaExtraUpdates(PlatformKimi, tiers, now)
  158. require.Equal(t, 40.0, updates["kimi_5h_used_percent"])
  159. require.Equal(t, "2026-08-14T15:00:00Z", updates["kimi_5h_reset_at"])
  160. require.Equal(t, 60.0, updates["kimi_weekly_used_percent"])
  161. require.Equal(t, "2026-08-18T00:00:00Z", updates["kimi_weekly_reset_at"])
  162. require.Equal(t, now.Format(time.RFC3339), updates["kimi_usage_updated_at"])
  163. }
  164. // TestCNProviderResponseIndicatesInsufficientBalance 覆盖中英文余额不足文案与否定用例。
  165. func TestCNProviderResponseIndicatesInsufficientBalance(t *testing.T) {
  166. t.Parallel()
  167. positive := []string{
  168. `{"error":{"message":"余额不足"}}`,
  169. `{"error":{"message":"Insufficient balance"}}`,
  170. `{"code":"insufficient_credit"}`,
  171. `"balance is not enough"`,
  172. `"no enough balance"`,
  173. }
  174. for _, body := range positive {
  175. require.True(t, cnProviderResponseIndicatesInsufficientBalance([]byte(body)), body)
  176. }
  177. negative := []string{
  178. `{"error":{"message":"rate limit exceeded"}}`,
  179. `{"error":{"message":"quota exhausted"}}`,
  180. ``,
  181. }
  182. for _, body := range negative {
  183. require.False(t, cnProviderResponseIndicatesInsufficientBalance([]byte(body)), body)
  184. }
  185. }
  186. // TestCNBalanceLowReason 验证稳定前缀(供周期检测任务识别并清除)。
  187. func TestCNBalanceLowReason(t *testing.T) {
  188. t.Parallel()
  189. require.Equal(t, "cn_balance_low: upstream said x",
  190. cnBalanceLowReason("upstream said x"))
  191. require.Equal(t, "cn_balance_low: 余额不足,账号临时停调",
  192. cnBalanceLowReason(" "))
  193. require.True(t, len(cnBalanceLowReason("")) > len(cnBalanceLowReasonPrefix))
  194. }
  195. // TestZhipuQuotaHost 按域名路由智谱额度端点主机(bigmodel.cn / z.ai / 默认国内站)。
  196. func TestZhipuQuotaHost(t *testing.T) {
  197. t.Parallel()
  198. require.Equal(t, "https://open.bigmodel.cn", zhipuQuotaHost("https://open.bigmodel.cn/api/paas/v4"))
  199. require.Equal(t, "https://api.z.ai", zhipuQuotaHost("https://api.z.ai/api/paas/v4"))
  200. require.Equal(t, "https://open.bigmodel.cn", zhipuQuotaHost("https://custom.example.com")) // 默认国内站
  201. require.Equal(t, "https://open.bigmodel.cn/api/monitor/usage/quota/limit", zhipuQuotaURL("https://open.bigmodel.cn/api/paas/v4"))
  202. }
  203. // TestKimiQuotaURL 两种协议默认 base(coding/v1 与 coding)都归一到 /coding/v1/usages
  204. // (cc-switch 固定端点;无 /v1 的 /coding/usages 实测 404)。
  205. func TestKimiQuotaURL(t *testing.T) {
  206. t.Parallel()
  207. require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding/v1"))
  208. require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding"))
  209. require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding/"))
  210. require.Equal(t, "https://api.kimi.com/coding/v1/usages", kimiQuotaURL("https://api.kimi.com/coding/v1/"))
  211. }
  212. func TestMiniMaxQuotaURL(t *testing.T) {
  213. t.Parallel()
  214. require.Equal(t, "https://api.minimax.io/v1/api/openplatform/coding_plan/remains",
  215. minimaxQuotaURL("https://api.minimax.io/v1"))
  216. require.Equal(t, "https://api.minimax.io/v1/api/openplatform/coding_plan/remains",
  217. minimaxQuotaURL("https://api.minimax.io/anthropic"))
  218. require.Equal(t, "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains",
  219. minimaxQuotaURL("https://api.minimaxi.com/v1"))
  220. require.Equal(t, "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains",
  221. minimaxQuotaURL("https://api.minimax.com/v1"))
  222. require.Equal(t, "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains",
  223. minimaxQuotaURL("https://custom.example.com"))
  224. }
  225. func TestParseMiniMaxUsageTiers(t *testing.T) {
  226. t.Parallel()
  227. endMs := int64(1_700_000_000_000)
  228. weeklyEndMs := endMs + 7*24*60*60*1000
  229. body := []byte(`{
  230. "base_resp": {"status_code": 0},
  231. "current_subscribe_title": "Max",
  232. "model_remains": [
  233. {"model_name": "video", "current_interval_remaining_percent": 10},
  234. {
  235. "model_name": "general",
  236. "current_interval_remaining_percent": 25,
  237. "end_time": 1700000000000,
  238. "current_weekly_status": 1,
  239. "current_weekly_remaining_percent": 40,
  240. "weekly_end_time": 1700604800000
  241. }
  242. ]
  243. }`)
  244. tiers := parseMiniMaxUsageTiers(body)
  245. require.Len(t, tiers, 2)
  246. require.Equal(t, "5h", tiers[0].Window)
  247. require.InDelta(t, 75, tiers[0].UsedPercent, 1e-9)
  248. require.Equal(t, time.UnixMilli(endMs).UTC().Format(time.RFC3339), tiers[0].ResetAt)
  249. require.Equal(t, "weekly", tiers[1].Window)
  250. require.InDelta(t, 60, tiers[1].UsedPercent, 1e-9)
  251. require.Equal(t, time.UnixMilli(weeklyEndMs).UTC().Format(time.RFC3339), tiers[1].ResetAt)
  252. noWeekly := parseMiniMaxUsageTiers([]byte(`{
  253. "model_remains": [{
  254. "model_name": "general",
  255. "current_interval_remaining_percent": 80,
  256. "end_time": 1700000000000,
  257. "current_weekly_status": 3,
  258. "current_weekly_remaining_percent": 10
  259. }]
  260. }`))
  261. require.Len(t, noWeekly, 1)
  262. require.Equal(t, "5h", noWeekly[0].Window)
  263. require.InDelta(t, 20, noWeekly[0].UsedPercent, 1e-9)
  264. require.Nil(t, parseMiniMaxUsageTiers([]byte(`{"model_remains":[{"model_name":"video","current_interval_remaining_percent":5}]}`)))
  265. }
  266. func TestGetCodingPlanProvider_MiniMax(t *testing.T) {
  267. t.Parallel()
  268. coding := &Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{
  269. "account_mode": AccountModeCoding,
  270. "base_url": "https://api.minimaxi.com/v1",
  271. }}
  272. require.Equal(t, PlatformMiniMax, coding.GetCodingPlanProvider())
  273. intl := &Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{
  274. "account_mode": AccountModeCoding,
  275. "base_url": "https://api.minimax.io/anthropic",
  276. }}
  277. require.Equal(t, PlatformMiniMax, intl.GetCodingPlanProvider())
  278. // 未配 base_url 时走国内站默认域名,仍能识别官方额度端点。
  279. require.Equal(t, PlatformMiniMax, (&Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{
  280. "account_mode": AccountModeCoding,
  281. }}).GetCodingPlanProvider())
  282. require.Empty(t, (&Account{Platform: PlatformMiniMax, Credentials: map[string]any{"account_mode": AccountModePayG}}).GetCodingPlanProvider())
  283. // 自定义中转不得把第三方 Key 发往官方额度端点。
  284. require.Empty(t, (&Account{Platform: PlatformMiniMax, Type: AccountTypeAPIKey, Credentials: map[string]any{
  285. "account_mode": AccountModeCoding,
  286. "base_url": "https://relay.example.com/v1",
  287. }}).GetCodingPlanProvider())
  288. }
  289. // TestCNBalanceURL Kimi 固定端点;DeepSeek 基于 base_url 拼接。
  290. func TestCNBalanceURL(t *testing.T) {
  291. t.Parallel()
  292. kimi := &Account{Platform: PlatformKimi}
  293. require.Equal(t, "https://api.moonshot.cn/v1/users/me/balance", cnBalanceURL(kimi))
  294. deepseek := &Account{
  295. Platform: PlatformDeepseek,
  296. Credentials: map[string]any{"base_url": "https://api.deepseek.com"},
  297. }
  298. require.Equal(t, "https://api.deepseek.com/user/balance", cnBalanceURL(deepseek))
  299. }
  300. // TestCNProviderThresholdCandidates 从 Extra 快照读取 5h / weekly 候选。
  301. func TestCNProviderThresholdCandidates(t *testing.T) {
  302. t.Parallel()
  303. account := &Account{
  304. Platform: PlatformKimi,
  305. Extra: map[string]any{
  306. "kimi_5h_used_percent": 90.0,
  307. "kimi_5h_reset_at": "2026-08-14T15:00:00Z",
  308. "kimi_weekly_used_percent": 50.0,
  309. "kimi_weekly_reset_at": "2026-08-18T00:00:00Z",
  310. },
  311. }
  312. cands := cnProviderThresholdCandidates(account, PlatformKimi)
  313. // 仅返回非 nil 候选(两窗口均存在 → 2 条)。
  314. var present []*accountSchedulingThresholdCandidate
  315. for _, c := range cands {
  316. if c != nil {
  317. present = append(present, c)
  318. }
  319. }
  320. require.Len(t, present, 2)
  321. // 缺少 used 键的窗口不产生候选。
  322. partial := &Account{
  323. Platform: PlatformKimi,
  324. Extra: map[string]any{"kimi_5h_reset_at": "2026-08-14T15:00:00Z"}, // 无 used_percent
  325. }
  326. require.Empty(t, filterNil(cnProviderThresholdCandidates(partial, PlatformKimi)))
  327. // 空 Extra / nil account 安全返回。
  328. require.Empty(t, filterNil(cnProviderThresholdCandidates(&Account{Platform: PlatformKimi}, PlatformKimi)))
  329. }
  330. func filterNil(cands []*accountSchedulingThresholdCandidate) []*accountSchedulingThresholdCandidate {
  331. var out []*accountSchedulingThresholdCandidate
  332. for _, c := range cands {
  333. if c != nil {
  334. out = append(out, c)
  335. }
  336. }
  337. return out
  338. }
  339. // TestEvaluateAccountSchedulingThreshold_KimiCodingPlan 集成验证:kimi coding 账号
  340. // 5h 用量超阈值且窗口未重置 → 主动停调至 5h 重置点。
  341. func TestEvaluateAccountSchedulingThreshold_KimiCodingPlan(t *testing.T) {
  342. t.Parallel()
  343. now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
  344. reset := now.Add(3 * time.Hour)
  345. account := &Account{
  346. Platform: PlatformKimi,
  347. Extra: map[string]any{
  348. "kimi_5h_used_percent": 90.0,
  349. "kimi_5h_reset_at": reset.Format(time.RFC3339),
  350. "kimi_weekly_used_percent": 30.0,
  351. "kimi_weekly_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339),
  352. },
  353. }
  354. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformKimi: 80}, now)
  355. require.True(t, decision.ShouldPause)
  356. require.Equal(t, PlatformKimi, decision.Platform)
  357. require.Equal(t, "5h", decision.Window)
  358. require.InDelta(t, 90.0, decision.UsedPercent, 1e-9)
  359. require.NotNil(t, decision.Until)
  360. require.True(t, reset.Equal(*decision.Until))
  361. }
  362. func TestEvaluateAccountSchedulingThreshold_MiniMaxCodingPlan(t *testing.T) {
  363. t.Parallel()
  364. now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
  365. reset := now.Add(3 * time.Hour)
  366. account := &Account{
  367. Platform: PlatformMiniMax,
  368. Extra: map[string]any{
  369. "minimax_5h_used_percent": 90.0,
  370. "minimax_5h_reset_at": reset.Format(time.RFC3339),
  371. "minimax_weekly_used_percent": 30.0,
  372. "minimax_weekly_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339),
  373. },
  374. }
  375. decision := EvaluateAccountSchedulingThreshold(account, map[string]int{PlatformMiniMax: 80}, now)
  376. require.True(t, decision.ShouldPause)
  377. require.Equal(t, PlatformMiniMax, decision.Platform)
  378. require.Equal(t, "5h", decision.Window)
  379. require.InDelta(t, 90.0, decision.UsedPercent, 1e-9)
  380. require.NotNil(t, decision.Until)
  381. require.True(t, reset.Equal(*decision.Until))
  382. }
  383. // TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped 窗口已重置(reset<=now)
  384. // 或用量低于阈值 → 不停调(candidateMatchesThreshold 要求 until.After(now))。
  385. func TestEvaluateAccountSchedulingThreshold_CNWindowResetSkipped(t *testing.T) {
  386. t.Parallel()
  387. now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
  388. // 重置时间已过。
  389. expired := &Account{
  390. Platform: PlatformZhipu,
  391. Extra: map[string]any{
  392. "zhipu_5h_used_percent": 99.0,
  393. "zhipu_5h_reset_at": now.Add(-1 * time.Hour).Format(time.RFC3339),
  394. },
  395. }
  396. require.False(t, EvaluateAccountSchedulingThreshold(expired, map[string]int{PlatformZhipu: 80}, now).ShouldPause)
  397. // 用量低于阈值。
  398. low := &Account{
  399. Platform: PlatformZhipu,
  400. Extra: map[string]any{
  401. "zhipu_5h_used_percent": 20.0,
  402. "zhipu_5h_reset_at": now.Add(3 * time.Hour).Format(time.RFC3339),
  403. },
  404. }
  405. require.False(t, EvaluateAccountSchedulingThreshold(low, map[string]int{PlatformZhipu: 80}, now).ShouldPause)
  406. }
  407. // TestCNProviderQuotaSnapshotReset Coding Plan 429 冷却:取快照中最早的「仍在未来」窗口重置点。
  408. func TestCNProviderQuotaSnapshotReset(t *testing.T) {
  409. t.Parallel()
  410. now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
  411. future5h := now.Add(2 * time.Hour)
  412. futureWeekly := now.Add(3 * 24 * time.Hour)
  413. pastWeekly := now.Add(-24 * time.Hour)
  414. // 5h 在未来、weekly 已过期 → 返回 5h。
  415. account := &Account{
  416. Platform: PlatformKimi,
  417. Credentials: map[string]any{"account_mode": AccountModeCoding},
  418. Extra: map[string]any{
  419. "kimi_5h_reset_at": future5h.Format(time.RFC3339),
  420. "kimi_weekly_reset_at": pastWeekly.Format(time.RFC3339),
  421. },
  422. }
  423. got := cnProviderQuotaSnapshotReset(account, now)
  424. require.NotNil(t, got)
  425. require.True(t, future5h.Equal(*got))
  426. // 两窗口均在未来 → 取较早者(429 多由 5h 窗口触发,避免冷却到 weekly 重置)。
  427. both := &Account{
  428. Platform: PlatformKimi,
  429. Credentials: map[string]any{"account_mode": AccountModeCoding},
  430. Extra: map[string]any{
  431. "kimi_5h_reset_at": future5h.Format(time.RFC3339),
  432. "kimi_weekly_reset_at": futureWeekly.Format(time.RFC3339),
  433. },
  434. }
  435. gotBoth := cnProviderQuotaSnapshotReset(both, now)
  436. require.NotNil(t, gotBoth)
  437. require.True(t, future5h.Equal(*gotBoth))
  438. // 两窗口均过期 → nil。
  439. expired := &Account{
  440. Platform: PlatformKimi,
  441. Credentials: map[string]any{"account_mode": AccountModeCoding},
  442. Extra: map[string]any{
  443. "kimi_5h_reset_at": pastWeekly.Format(time.RFC3339),
  444. "kimi_weekly_reset_at": pastWeekly.Format(time.RFC3339),
  445. },
  446. }
  447. require.Nil(t, cnProviderQuotaSnapshotReset(expired, now))
  448. // payg 账号(非 coding)→ nil(余额型走余额检测)。
  449. payg := &Account{
  450. Platform: PlatformKimi,
  451. Credentials: map[string]any{"account_mode": AccountModePayG},
  452. Extra: map[string]any{"kimi_5h_reset_at": future5h.Format(time.RFC3339)},
  453. }
  454. require.Nil(t, cnProviderQuotaSnapshotReset(payg, now))
  455. }
  456. // TestNormalizeOpenAICompatiblePlatform_SchedulerExactMatch 回归保护:
  457. // grok 与国产供应商原样保留,其余归一为 openai —— 保证 kimi/zhipu/deepseek 分组请求
  458. // 精确匹配同名账号(与 openai/grok 当前行为一致),不会错误并入 openai 池。
  459. func TestNormalizeOpenAICompatiblePlatform_SchedulerExactMatch(t *testing.T) {
  460. t.Parallel()
  461. require.Equal(t, PlatformGrok, NormalizeOpenAICompatiblePlatform(PlatformGrok))
  462. require.Equal(t, PlatformKimi, NormalizeOpenAICompatiblePlatform(PlatformKimi))
  463. require.Equal(t, PlatformZhipu, NormalizeOpenAICompatiblePlatform(PlatformZhipu))
  464. require.Equal(t, PlatformDeepseek, NormalizeOpenAICompatiblePlatform(PlatformDeepseek))
  465. // 其他平台(含空、anthropic、未知)一律归一为 openai。
  466. require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(""))
  467. require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform(PlatformAnthropic))
  468. require.Equal(t, PlatformOpenAI, NormalizeOpenAICompatiblePlatform("something-else"))
  469. }
  470. // TestGetOpenAIProtocolAPIKey_CNProviders 验证 OpenAI 协议族密钥读取覆盖国产供应商,
  471. // 同时保持 IsOpenAIApiKey 的 openai-only 语义(调度倍率/WS 门控不受影响)。
  472. func TestGetOpenAIProtocolAPIKey_CNProviders(t *testing.T) {
  473. t.Parallel()
  474. kimi := &Account{
  475. Platform: PlatformKimi,
  476. Type: AccountTypeAPIKey,
  477. Credentials: map[string]any{"api_key": "sk-kimi"},
  478. }
  479. require.Equal(t, "sk-kimi", kimi.GetOpenAIProtocolAPIKey())
  480. require.False(t, kimi.IsOpenAIApiKey(), "IsOpenAIApiKey stays openai-only for scheduling gates")
  481. // 非 APIKey 类型的 CN 账号不返回密钥
  482. notAPIKey := &Account{
  483. Platform: PlatformDeepseek,
  484. Type: AccountTypeOAuth,
  485. Credentials: map[string]any{"api_key": "sk-leak"},
  486. }
  487. require.Equal(t, "", notAPIKey.GetOpenAIProtocolAPIKey())
  488. // openai 原生账号行为不变
  489. openai := &Account{
  490. Platform: PlatformOpenAI,
  491. Type: AccountTypeAPIKey,
  492. Credentials: map[string]any{"api_key": "sk-openai"},
  493. }
  494. require.Equal(t, "sk-openai", openai.GetOpenAIProtocolAPIKey())
  495. }
  496. // TestBuildUpstreamModelsRequest_CNProviders 验证“同步上游支持的模型”对国产供应商可用:
  497. // 密钥经 GetOpenAIProtocolAPIKey 读取,/models 端点拼接到账号 base_url(含默认值)。
  498. func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) {
  499. t.Parallel()
  500. svc := &AccountTestService{cfg: &config.Config{}}
  501. cases := []struct {
  502. name string
  503. platform string
  504. mode string
  505. wantURL string
  506. }{
  507. {"kimi default", PlatformKimi, "", "https://api.moonshot.cn/v1/models"},
  508. {"kimi coding", PlatformKimi, AccountModeCoding, "https://api.kimi.com/coding/v1/models"},
  509. {"zhipu default", PlatformZhipu, "", "https://open.bigmodel.cn/api/paas/v4/models"},
  510. {"zhipu coding", PlatformZhipu, AccountModeCoding, "https://open.bigmodel.cn/api/coding/paas/v4/models"},
  511. {"deepseek", PlatformDeepseek, "", "https://api.deepseek.com/v1/models"},
  512. {"minimax default", PlatformMiniMax, "", "https://api.minimaxi.com/v1/models"},
  513. {"minimax coding", PlatformMiniMax, AccountModeCoding, "https://api.minimaxi.com/v1/models"},
  514. }
  515. for _, tc := range cases {
  516. t.Run(tc.name, func(t *testing.T) {
  517. t.Parallel()
  518. creds := map[string]any{"api_key": "sk-test"}
  519. if tc.mode != "" {
  520. creds["account_mode"] = tc.mode
  521. }
  522. account := &Account{ID: 1, Platform: tc.platform, Type: AccountTypeAPIKey, Credentials: creds}
  523. req, err := svc.buildUpstreamModelsRequest(context.Background(), account)
  524. require.NoError(t, err)
  525. require.Equal(t, tc.wantURL, req.URL.String())
  526. require.Equal(t, "Bearer sk-test", req.Header.Get("Authorization"))
  527. })
  528. }
  529. }
  530. // TestGetAPIProtocol 验证协议凭证维度的平台校验矩阵:
  531. // responses 仅 deepseek / kimi;缺失/非法值回退 chat_completions(与旧行为一致)。
  532. func TestGetAPIProtocol(t *testing.T) {
  533. t.Parallel()
  534. mk := func(platform, protocol string) *Account {
  535. creds := map[string]any{"api_key": "sk-test"}
  536. if protocol != "" {
  537. creds["api_protocol"] = protocol
  538. }
  539. return &Account{Platform: platform, Type: AccountTypeAPIKey, Credentials: creds}
  540. }
  541. require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "").GetAPIProtocol(), "缺失回退默认")
  542. require.Equal(t, APIProtocolAnthropic, mk(PlatformZhipu, APIProtocolAnthropic).GetAPIProtocol())
  543. require.Equal(t, APIProtocolAnthropic, mk(PlatformKimi, APIProtocolAnthropic).GetAPIProtocol())
  544. require.Equal(t, APIProtocolAnthropic, mk(PlatformDeepseek, APIProtocolAnthropic).GetAPIProtocol())
  545. require.Equal(t, APIProtocolResponses, mk(PlatformDeepseek, APIProtocolResponses).GetAPIProtocol())
  546. require.Equal(t, APIProtocolResponses, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol())
  547. require.Equal(t, APIProtocolResponses, mk(PlatformMiniMax, APIProtocolResponses).GetAPIProtocol())
  548. require.Equal(t, APIProtocolAdaptive, mk(PlatformKimi, APIProtocolAdaptive).GetAPIProtocol())
  549. require.Equal(t, APIProtocolAdaptive, mk(PlatformMiniMax, APIProtocolAdaptive).GetAPIProtocol())
  550. require.Equal(t, APIProtocolAdaptive, mk(PlatformZhipu, APIProtocolAdaptive).GetAPIProtocol())
  551. require.Equal(t, APIProtocolAdaptive, mk(PlatformDeepseek, APIProtocolAdaptive).GetAPIProtocol())
  552. require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点")
  553. require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认")
  554. require.Equal(t, APIProtocolChatCompletions, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).GetAPIProtocol(), "非 CN 供应商恒为默认")
  555. }
  556. func TestSupportsNativeCNResponses(t *testing.T) {
  557. t.Parallel()
  558. require.True(t, (&Account{Platform: PlatformDeepseek}).SupportsNativeCNResponses())
  559. require.True(t, (&Account{Platform: PlatformKimi}).SupportsNativeCNResponses())
  560. require.True(t, (&Account{Platform: PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}).SupportsNativeCNResponses())
  561. require.True(t, (&Account{Platform: PlatformMiniMax}).SupportsNativeCNResponses())
  562. require.False(t, (&Account{Platform: PlatformZhipu}).SupportsNativeCNResponses())
  563. require.False(t, (&Account{Platform: PlatformOpenAI}).SupportsNativeCNResponses())
  564. }
  565. func TestAdaptiveProtocolBaseURLs(t *testing.T) {
  566. t.Parallel()
  567. cases := []struct {
  568. name string
  569. platform string
  570. mode string
  571. wantChat string
  572. wantAnthropic string
  573. wantResponses string
  574. }{
  575. {"kimi payg", PlatformKimi, AccountModePayG, DefaultKimiPayGBaseURL, DefaultKimiPayGAnthropicBaseURL, DefaultKimiPayGBaseURL},
  576. {"kimi coding", PlatformKimi, AccountModeCoding, DefaultKimiCodingBaseURL, DefaultKimiCodingAnthropicBaseURL, DefaultKimiCodingBaseURL},
  577. {"zhipu payg", PlatformZhipu, AccountModePayG, DefaultZhipuPayGBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuPayGBaseURL},
  578. {"zhipu coding", PlatformZhipu, AccountModeCoding, DefaultZhipuCodingBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuCodingBaseURL},
  579. {"deepseek", PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL, DefaultDeepseekAnthropicBaseURL, DefaultDeepseekBaseURL},
  580. {"minimax payg", PlatformMiniMax, AccountModePayG, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL},
  581. {"minimax coding", PlatformMiniMax, AccountModeCoding, DefaultMiniMaxBaseURL, DefaultMiniMaxAnthropicBaseURL, DefaultMiniMaxBaseURL},
  582. }
  583. for _, tc := range cases {
  584. t.Run(tc.name, func(t *testing.T) {
  585. account := &Account{Platform: tc.platform, Type: AccountTypeAPIKey, Credentials: map[string]any{
  586. "api_protocol": APIProtocolAdaptive,
  587. "account_mode": tc.mode,
  588. }}
  589. require.Equal(t, tc.wantChat, account.GetCNProtocolBaseURL(APIProtocolChatCompletions))
  590. require.Equal(t, tc.wantAnthropic, account.GetCNProtocolBaseURL(APIProtocolAnthropic))
  591. require.Equal(t, tc.wantResponses, account.GetCNProtocolBaseURL(APIProtocolResponses))
  592. require.Equal(t, tc.wantAnthropic, account.GetAnthropicProtocolBaseURL())
  593. })
  594. }
  595. }
  596. func TestAdaptiveProtocolBaseURLOverrides(t *testing.T) {
  597. t.Parallel()
  598. account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Credentials: map[string]any{
  599. "api_protocol": APIProtocolAdaptive,
  600. "base_url": "https://legacy-chat.example.com",
  601. "api_base_urls": map[string]any{
  602. APIProtocolChatCompletions: "https://chat.example.com",
  603. APIProtocolAnthropic: "https://anthropic.example.com",
  604. APIProtocolResponses: "https://responses.example.com",
  605. },
  606. }}
  607. require.Equal(t, "https://chat.example.com", account.GetOpenAIBaseURL())
  608. require.Equal(t, "https://chat.example.com", account.GetCNProtocolBaseURL(APIProtocolChatCompletions))
  609. require.Equal(t, "https://anthropic.example.com", account.GetAnthropicProtocolBaseURL())
  610. require.Equal(t, "https://responses.example.com", account.GetCNProtocolBaseURL(APIProtocolResponses))
  611. }
  612. // TestAnthropicProtocolBaseURL 验证 Anthropic 协议默认端点与协议感知的
  613. // OpenAI 格式 base 回退。
  614. func TestAnthropicProtocolBaseURL(t *testing.T) {
  615. t.Parallel()
  616. // 默认端点(按供应商 × 模式)
  617. require.Equal(t, "https://api.moonshot.cn/anthropic", (&Account{
  618. Platform: PlatformKimi, Type: AccountTypeAPIKey,
  619. Credentials: map[string]any{"api_protocol": APIProtocolAnthropic},
  620. }).GetAnthropicProtocolBaseURL())
  621. require.Equal(t, "https://api.kimi.com/coding", (&Account{
  622. Platform: PlatformKimi, Type: AccountTypeAPIKey,
  623. Credentials: map[string]any{"api_protocol": APIProtocolAnthropic, "account_mode": AccountModeCoding},
  624. }).GetAnthropicProtocolBaseURL())
  625. require.Equal(t, "https://open.bigmodel.cn/api/anthropic", (&Account{
  626. Platform: PlatformZhipu, Type: AccountTypeAPIKey,
  627. Credentials: map[string]any{"api_protocol": APIProtocolAnthropic},
  628. }).GetAnthropicProtocolBaseURL())
  629. require.Equal(t, "https://api.deepseek.com/anthropic", (&Account{
  630. Platform: PlatformDeepseek, Type: AccountTypeAPIKey,
  631. Credentials: map[string]any{"api_protocol": APIProtocolAnthropic},
  632. }).GetAnthropicProtocolBaseURL())
  633. require.Equal(t, "https://api.minimaxi.com/anthropic", (&Account{
  634. Platform: PlatformMiniMax, Type: AccountTypeAPIKey,
  635. Credentials: map[string]any{"api_protocol": APIProtocolAnthropic},
  636. }).GetAnthropicProtocolBaseURL())
  637. // 凭证 base_url 覆盖默认值
  638. require.Equal(t, "https://custom.example.com/anthropic", (&Account{
  639. Platform: PlatformZhipu, Type: AccountTypeAPIKey,
  640. Credentials: map[string]any{"api_protocol": APIProtocolAnthropic, "base_url": "https://custom.example.com/anthropic"},
  641. }).GetAnthropicProtocolBaseURL())
  642. // 非 Anthropic 协议返回空串
  643. require.Empty(t, (&Account{
  644. Platform: PlatformZhipu, Type: AccountTypeAPIKey,
  645. Credentials: map[string]any{"base_url": "https://open.bigmodel.cn/api/paas/v4"},
  646. }).GetAnthropicProtocolBaseURL())
  647. }
  648. // TestGetOpenAIFormatBaseURL_ProtocolAware anthropic 协议账号的凭证 base_url
  649. // 指向 Anthropic 端点,OpenAI 格式路径(模型同步等)必须回退到 CC 默认 base。
  650. func TestGetOpenAIFormatBaseURL_ProtocolAware(t *testing.T) {
  651. t.Parallel()
  652. zhipuAnthropic := &Account{
  653. Platform: PlatformZhipu, Type: AccountTypeAPIKey,
  654. Credentials: map[string]any{
  655. "api_protocol": APIProtocolAnthropic,
  656. "base_url": "https://open.bigmodel.cn/api/anthropic",
  657. },
  658. }
  659. require.Equal(t, "https://open.bigmodel.cn/api/paas/v4", zhipuAnthropic.GetOpenAIFormatBaseURL())
  660. kimiCodingAnthropic := &Account{
  661. Platform: PlatformKimi, Type: AccountTypeAPIKey,
  662. Credentials: map[string]any{
  663. "api_protocol": APIProtocolAnthropic,
  664. "account_mode": AccountModeCoding,
  665. "base_url": "https://api.kimi.com/coding",
  666. },
  667. }
  668. require.Equal(t, "https://api.kimi.com/coding/v1", kimiCodingAnthropic.GetOpenAIFormatBaseURL())
  669. // chat_completions 协议下行为不变(凭证 base_url 原样返回)
  670. ccAccount := &Account{
  671. Platform: PlatformDeepseek, Type: AccountTypeAPIKey,
  672. Credentials: map[string]any{"base_url": "https://ds-relay.example.com"},
  673. }
  674. require.Equal(t, "https://ds-relay.example.com", ccAccount.GetOpenAIFormatBaseURL())
  675. }
  676. // TestBuildUpstreamModelsRequest_AnthropicProtocol 模型同步使用协议感知 base。
  677. func TestBuildUpstreamModelsRequest_AnthropicProtocol(t *testing.T) {
  678. t.Parallel()
  679. svc := &AccountTestService{cfg: &config.Config{}}
  680. account := &Account{
  681. ID: 1, Platform: PlatformZhipu, Type: AccountTypeAPIKey,
  682. Credentials: map[string]any{
  683. "api_key": "sk-test",
  684. "api_protocol": APIProtocolAnthropic,
  685. "base_url": "https://open.bigmodel.cn/api/anthropic",
  686. },
  687. }
  688. req, err := svc.buildUpstreamModelsRequest(context.Background(), account)
  689. require.NoError(t, err)
  690. require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/models", req.URL.String())
  691. }
  692. // TestBuildOpenAIResponsesURLForPlatform deepseek 官方端点为 /responses(无 /v1)。
  693. func TestBuildOpenAIResponsesURLForPlatform(t *testing.T) {
  694. t.Parallel()
  695. require.Equal(t, "https://api.deepseek.com/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://api.deepseek.com"))
  696. require.Equal(t, "https://relay.example.com/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://relay.example.com"))
  697. require.Equal(t, "https://relay.example.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://relay.example.com/v1"))
  698. require.Equal(t, "https://api.openai.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformOpenAI, "https://api.openai.com"))
  699. require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/responses", buildOpenAIResponsesURLForPlatform(PlatformZhipu, "https://open.bigmodel.cn/api/paas/v4"))
  700. require.Equal(t, "https://api.moonshot.cn/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformKimi, "https://api.moonshot.cn/v1"))
  701. require.Equal(t, "https://api.kimi.com/coding/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformKimi, "https://api.kimi.com/coding/v1"))
  702. }
  703. // TestNormalizeDeepSeekResponsesRequestBody 无状态适配:强制 store=false、
  704. // 清除 previous_response_id;非原生 CN Responses 协议原样返回。
  705. func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) {
  706. t.Parallel()
  707. deepseekResponses := &Account{
  708. Platform: PlatformDeepseek, Type: AccountTypeAPIKey,
  709. Credentials: map[string]any{"api_protocol": APIProtocolResponses},
  710. }
  711. body := []byte(`{"model":"deepseek-v4-pro","store":true,"previous_response_id":"resp_123","input":"hi"}`)
  712. normalized := normalizeDeepSeekResponsesRequestBody(deepseekResponses, body)
  713. require.False(t, gjson.GetBytes(normalized, "store").Bool())
  714. require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists())
  715. require.Equal(t, "deepseek-v4-pro", gjson.GetBytes(normalized, "model").String())
  716. deepseekAdaptive := &Account{
  717. Platform: PlatformDeepseek, Type: AccountTypeAPIKey,
  718. Credentials: map[string]any{"api_protocol": APIProtocolAdaptive},
  719. }
  720. adaptiveNormalized := normalizeDeepSeekResponsesRequestBody(deepseekAdaptive, body)
  721. require.False(t, gjson.GetBytes(adaptiveNormalized, "store").Bool())
  722. require.False(t, gjson.GetBytes(adaptiveNormalized, "previous_response_id").Exists())
  723. // 非 responses 协议(deepseek CC 账号)原样返回
  724. deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
  725. require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body)))
  726. kimiResponses := &Account{
  727. Platform: PlatformKimi, Type: AccountTypeAPIKey,
  728. Credentials: map[string]any{"api_protocol": APIProtocolResponses},
  729. }
  730. kimiNormalized := normalizeDeepSeekResponsesRequestBody(kimiResponses, body)
  731. require.False(t, gjson.GetBytes(kimiNormalized, "store").Bool())
  732. require.False(t, gjson.GetBytes(kimiNormalized, "previous_response_id").Exists())
  733. kimiCodingAdaptive := &Account{
  734. Platform: PlatformKimi, Type: AccountTypeAPIKey,
  735. Credentials: map[string]any{"api_protocol": APIProtocolAdaptive, "account_mode": AccountModeCoding},
  736. }
  737. kimiCodingNormalized := normalizeDeepSeekResponsesRequestBody(kimiCodingAdaptive, body)
  738. require.False(t, gjson.GetBytes(kimiCodingNormalized, "store").Bool())
  739. require.False(t, gjson.GetBytes(kimiCodingNormalized, "previous_response_id").Exists())
  740. // openai 账号原样返回
  741. openai := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  742. require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(openai, body)))
  743. }
  744. // TestGetAnthropicAPIKeyAuthScheme_CNProvider CN 账号可经 extra 覆写鉴权方案,
  745. // 默认保持 x-api-key。
  746. func TestGetAnthropicAPIKeyAuthScheme_CNProvider(t *testing.T) {
  747. t.Parallel()
  748. zhipu := &Account{
  749. Platform: PlatformZhipu, Type: AccountTypeAPIKey,
  750. Credentials: map[string]any{"api_protocol": APIProtocolAnthropic},
  751. }
  752. require.Equal(t, AnthropicAPIKeyAuthSchemeXAPIKey, zhipu.GetAnthropicAPIKeyAuthScheme())
  753. zhipu.Extra = map[string]any{"anthropic_apikey_auth_scheme": "authorization_bearer"}
  754. require.Equal(t, AnthropicAPIKeyAuthSchemeAuthorizationBearer, zhipu.GetAnthropicAPIKeyAuthScheme())
  755. }