providers-patch-contract.test.ts 33 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120
  1. import { describe, expect, it } from "vitest";
  2. import { PROVIDER_RULE_LIMITS } from "@/lib/constants/provider.constants";
  3. import {
  4. buildProviderBatchApplyUpdates,
  5. hasProviderBatchPatchChanges,
  6. normalizeProviderBatchPatchDraft,
  7. prepareProviderBatchApplyUpdates,
  8. PROVIDER_PATCH_ERROR_CODES,
  9. } from "@/lib/provider-patch-contract";
  10. describe("provider patch contract", () => {
  11. it("normalizes undefined fields as no_change and omits them from apply payload", () => {
  12. const normalized = normalizeProviderBatchPatchDraft({});
  13. expect(normalized.ok).toBe(true);
  14. if (!normalized.ok) return;
  15. expect(normalized.data.group_tag.mode).toBe("no_change");
  16. expect(hasProviderBatchPatchChanges(normalized.data)).toBe(false);
  17. const applyPayload = buildProviderBatchApplyUpdates(normalized.data);
  18. expect(applyPayload.ok).toBe(true);
  19. if (!applyPayload.ok) return;
  20. expect(applyPayload.data).toEqual({});
  21. });
  22. it("serializes set and clear with distinct payload shapes", () => {
  23. const setResult = prepareProviderBatchApplyUpdates({
  24. group_tag: { set: "primary" },
  25. allowed_models: { set: ["claude-3-7-sonnet"] },
  26. });
  27. const clearResult = prepareProviderBatchApplyUpdates({
  28. group_tag: { clear: true },
  29. allowed_models: { clear: true },
  30. });
  31. expect(setResult.ok).toBe(true);
  32. if (!setResult.ok) return;
  33. expect(clearResult.ok).toBe(true);
  34. if (!clearResult.ok) return;
  35. expect(setResult.data.group_tag).toBe("primary");
  36. expect(clearResult.data.group_tag).toBeNull();
  37. expect(setResult.data.allowed_models).toEqual([
  38. { matchType: "exact", pattern: "claude-3-7-sonnet" },
  39. ]);
  40. expect(clearResult.data.allowed_models).toBeNull();
  41. });
  42. it("maps empty allowed_models set payload to null", () => {
  43. const result = prepareProviderBatchApplyUpdates({
  44. allowed_models: { set: [] },
  45. });
  46. expect(result.ok).toBe(true);
  47. if (!result.ok) return;
  48. expect(result.data.allowed_models).toBeNull();
  49. });
  50. it("maps thinking budget clear to inherit", () => {
  51. const result = prepareProviderBatchApplyUpdates({
  52. anthropic_thinking_budget_preference: { clear: true },
  53. });
  54. expect(result.ok).toBe(true);
  55. if (!result.ok) return;
  56. expect(result.data.anthropic_thinking_budget_preference).toBe("inherit");
  57. });
  58. it("rejects conflicting set and clear modes", () => {
  59. const result = normalizeProviderBatchPatchDraft({
  60. group_tag: {
  61. set: "ops",
  62. clear: true,
  63. } as never,
  64. });
  65. expect(result.ok).toBe(false);
  66. if (result.ok) return;
  67. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  68. expect(result.error.field).toBe("group_tag");
  69. });
  70. it("rejects clear on non-clearable fields", () => {
  71. const result = normalizeProviderBatchPatchDraft({
  72. priority: {
  73. clear: true,
  74. } as never,
  75. });
  76. expect(result.ok).toBe(false);
  77. if (result.ok) return;
  78. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  79. expect(result.error.field).toBe("priority");
  80. });
  81. it("rejects invalid set runtime shape", () => {
  82. const result = normalizeProviderBatchPatchDraft({
  83. weight: {
  84. set: null,
  85. } as never,
  86. });
  87. expect(result.ok).toBe(false);
  88. if (result.ok) return;
  89. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  90. expect(result.error.field).toBe("weight");
  91. });
  92. it("accepts model_redirects with redirect rule array", () => {
  93. const result = normalizeProviderBatchPatchDraft({
  94. model_redirects: {
  95. set: [{ matchType: "prefix", source: "claude-opus", target: "glm-4.6" }],
  96. },
  97. });
  98. expect(result.ok).toBe(true);
  99. if (!result.ok) return;
  100. expect(result.data.model_redirects.mode).toBe("set");
  101. if (result.data.model_redirects.mode !== "set") return;
  102. expect(result.data.model_redirects.value).toEqual([
  103. { matchType: "prefix", source: "claude-opus", target: "glm-4.6" },
  104. ]);
  105. });
  106. it("rejects model_redirects with unsafe regex rule", () => {
  107. const result = normalizeProviderBatchPatchDraft({
  108. model_redirects: {
  109. set: [{ matchType: "regex", source: "(a+)+", target: "glm-4.6" }],
  110. },
  111. });
  112. expect(result.ok).toBe(false);
  113. if (result.ok) return;
  114. expect(result.error.field).toBe("model_redirects");
  115. });
  116. it("rejects model_redirects with overlong source", () => {
  117. const result = normalizeProviderBatchPatchDraft({
  118. model_redirects: {
  119. set: [
  120. {
  121. matchType: "exact",
  122. source: "a".repeat(PROVIDER_RULE_LIMITS.MAX_TEXT_LENGTH + 1),
  123. target: "glm-4.6",
  124. },
  125. ],
  126. },
  127. });
  128. expect(result.ok).toBe(false);
  129. if (result.ok) return;
  130. expect(result.error.field).toBe("model_redirects");
  131. });
  132. it("accepts allowed_clients with string array", () => {
  133. const result = normalizeProviderBatchPatchDraft({
  134. allowed_clients: { set: ["client-a", "client-b"] },
  135. });
  136. expect(result.ok).toBe(true);
  137. });
  138. it("rejects allowed_clients with non-string array", () => {
  139. const result = normalizeProviderBatchPatchDraft({
  140. allowed_clients: { set: [123] } as never,
  141. });
  142. expect(result.ok).toBe(false);
  143. if (result.ok) return;
  144. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  145. expect(result.error.field).toBe("allowed_clients");
  146. });
  147. it("accepts blocked_clients with string array", () => {
  148. const result = normalizeProviderBatchPatchDraft({
  149. blocked_clients: { set: ["bad-client"] },
  150. });
  151. expect(result.ok).toBe(true);
  152. });
  153. it("rejects blocked_clients with non-string array", () => {
  154. const result = normalizeProviderBatchPatchDraft({
  155. blocked_clients: { set: { not: "array" } } as never,
  156. });
  157. expect(result.ok).toBe(false);
  158. if (result.ok) return;
  159. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  160. expect(result.error.field).toBe("blocked_clients");
  161. });
  162. it("rejects invalid thinking budget string values", () => {
  163. const result = normalizeProviderBatchPatchDraft({
  164. anthropic_thinking_budget_preference: {
  165. set: "abc",
  166. } as never,
  167. });
  168. expect(result.ok).toBe(false);
  169. if (result.ok) return;
  170. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  171. expect(result.error.field).toBe("anthropic_thinking_budget_preference");
  172. });
  173. it("rejects adaptive thinking specific mode with empty models", () => {
  174. const result = normalizeProviderBatchPatchDraft({
  175. anthropic_adaptive_thinking: {
  176. set: {
  177. effort: "high",
  178. modelMatchMode: "specific",
  179. models: [],
  180. },
  181. },
  182. });
  183. expect(result.ok).toBe(false);
  184. if (result.ok) return;
  185. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  186. expect(result.error.field).toBe("anthropic_adaptive_thinking");
  187. });
  188. it("supports explicit no_change mode", () => {
  189. const result = normalizeProviderBatchPatchDraft({
  190. model_redirects: { no_change: true },
  191. });
  192. expect(result.ok).toBe(true);
  193. if (!result.ok) return;
  194. expect(result.data.model_redirects.mode).toBe("no_change");
  195. });
  196. it("rejects unknown top-level fields", () => {
  197. const result = normalizeProviderBatchPatchDraft({
  198. unknown_field: { set: 1 },
  199. } as never);
  200. expect(result.ok).toBe(false);
  201. if (result.ok) return;
  202. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  203. expect(result.error.field).toBe("__root__");
  204. });
  205. it("rejects non-object draft payloads", () => {
  206. const result = normalizeProviderBatchPatchDraft(null as never);
  207. expect(result.ok).toBe(false);
  208. if (result.ok) return;
  209. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  210. expect(result.error.field).toBe("__root__");
  211. });
  212. describe("routing fields", () => {
  213. it("accepts boolean set for preserve_client_ip and swap_cache_ttl_billing", () => {
  214. const result = prepareProviderBatchApplyUpdates({
  215. preserve_client_ip: { set: true },
  216. swap_cache_ttl_billing: { set: false },
  217. });
  218. expect(result.ok).toBe(true);
  219. if (!result.ok) return;
  220. expect(result.data.preserve_client_ip).toBe(true);
  221. expect(result.data.swap_cache_ttl_billing).toBe(false);
  222. });
  223. it("accepts group_priorities as Record<string, number>", () => {
  224. const result = prepareProviderBatchApplyUpdates({
  225. group_priorities: { set: { us: 10, eu: 5 } },
  226. });
  227. expect(result.ok).toBe(true);
  228. if (!result.ok) return;
  229. expect(result.data.group_priorities).toEqual({ us: 10, eu: 5 });
  230. });
  231. it("rejects group_priorities with non-number values", () => {
  232. const result = normalizeProviderBatchPatchDraft({
  233. group_priorities: { set: { us: "high" } } as never,
  234. });
  235. expect(result.ok).toBe(false);
  236. if (result.ok) return;
  237. expect(result.error.field).toBe("group_priorities");
  238. });
  239. it("rejects group_priorities when array", () => {
  240. const result = normalizeProviderBatchPatchDraft({
  241. group_priorities: { set: [1, 2, 3] } as never,
  242. });
  243. expect(result.ok).toBe(false);
  244. if (result.ok) return;
  245. expect(result.error.field).toBe("group_priorities");
  246. });
  247. it("clears group_priorities to null", () => {
  248. const result = prepareProviderBatchApplyUpdates({
  249. group_priorities: { clear: true },
  250. });
  251. expect(result.ok).toBe(true);
  252. if (!result.ok) return;
  253. expect(result.data.group_priorities).toBeNull();
  254. });
  255. it.each([
  256. ["cache_ttl_preference", "inherit"],
  257. ["cache_ttl_preference", "5m"],
  258. ["cache_ttl_preference", "1h"],
  259. ] as const)("accepts valid %s value: %s", (field, value) => {
  260. const result = prepareProviderBatchApplyUpdates({
  261. [field]: { set: value },
  262. });
  263. expect(result.ok).toBe(true);
  264. if (!result.ok) return;
  265. expect(result.data[field]).toBe(value);
  266. });
  267. it("rejects invalid cache_ttl_preference value", () => {
  268. const result = normalizeProviderBatchPatchDraft({
  269. cache_ttl_preference: { set: "30m" } as never,
  270. });
  271. expect(result.ok).toBe(false);
  272. if (result.ok) return;
  273. expect(result.error.field).toBe("cache_ttl_preference");
  274. });
  275. it.each([
  276. ["context_1m_preference", "inherit"],
  277. ["context_1m_preference", "force_enable"],
  278. ["context_1m_preference", "disabled"],
  279. ] as const)("accepts valid %s value: %s", (field, value) => {
  280. const result = prepareProviderBatchApplyUpdates({
  281. [field]: { set: value },
  282. });
  283. expect(result.ok).toBe(true);
  284. if (!result.ok) return;
  285. expect(result.data[field]).toBe(value);
  286. });
  287. it.each([
  288. ["codex_reasoning_effort_preference", "inherit"],
  289. ["codex_reasoning_effort_preference", "none"],
  290. ["codex_reasoning_effort_preference", "minimal"],
  291. ["codex_reasoning_effort_preference", "low"],
  292. ["codex_reasoning_effort_preference", "medium"],
  293. ["codex_reasoning_effort_preference", "high"],
  294. ["codex_reasoning_effort_preference", "xhigh"],
  295. ] as const)("accepts valid %s value: %s", (field, value) => {
  296. const result = prepareProviderBatchApplyUpdates({
  297. [field]: { set: value },
  298. });
  299. expect(result.ok).toBe(true);
  300. if (!result.ok) return;
  301. expect(result.data[field]).toBe(value);
  302. });
  303. it("rejects invalid codex_reasoning_effort_preference value", () => {
  304. const result = normalizeProviderBatchPatchDraft({
  305. codex_reasoning_effort_preference: { set: "ultra" } as never,
  306. });
  307. expect(result.ok).toBe(false);
  308. if (result.ok) return;
  309. expect(result.error.field).toBe("codex_reasoning_effort_preference");
  310. });
  311. it.each([
  312. ["codex_reasoning_summary_preference", "inherit"],
  313. ["codex_reasoning_summary_preference", "auto"],
  314. ["codex_reasoning_summary_preference", "detailed"],
  315. ] as const)("accepts valid %s value: %s", (field, value) => {
  316. const result = prepareProviderBatchApplyUpdates({
  317. [field]: { set: value },
  318. });
  319. expect(result.ok).toBe(true);
  320. if (!result.ok) return;
  321. expect(result.data[field]).toBe(value);
  322. });
  323. it.each([
  324. ["codex_text_verbosity_preference", "inherit"],
  325. ["codex_text_verbosity_preference", "low"],
  326. ["codex_text_verbosity_preference", "medium"],
  327. ["codex_text_verbosity_preference", "high"],
  328. ] as const)("accepts valid %s value: %s", (field, value) => {
  329. const result = prepareProviderBatchApplyUpdates({
  330. [field]: { set: value },
  331. });
  332. expect(result.ok).toBe(true);
  333. if (!result.ok) return;
  334. expect(result.data[field]).toBe(value);
  335. });
  336. it.each([
  337. ["codex_parallel_tool_calls_preference", "inherit"],
  338. ["codex_parallel_tool_calls_preference", "true"],
  339. ["codex_parallel_tool_calls_preference", "false"],
  340. ] as const)("accepts valid %s value: %s", (field, value) => {
  341. const result = prepareProviderBatchApplyUpdates({
  342. [field]: { set: value },
  343. });
  344. expect(result.ok).toBe(true);
  345. if (!result.ok) return;
  346. expect(result.data[field]).toBe(value);
  347. });
  348. it.each([
  349. ["gemini_google_search_preference", "inherit"],
  350. ["gemini_google_search_preference", "enabled"],
  351. ["gemini_google_search_preference", "disabled"],
  352. ] as const)("accepts valid %s value: %s", (field, value) => {
  353. const result = prepareProviderBatchApplyUpdates({
  354. [field]: { set: value },
  355. });
  356. expect(result.ok).toBe(true);
  357. if (!result.ok) return;
  358. expect(result.data[field]).toBe(value);
  359. });
  360. it("rejects invalid gemini_google_search_preference value", () => {
  361. const result = normalizeProviderBatchPatchDraft({
  362. gemini_google_search_preference: { set: "auto" } as never,
  363. });
  364. expect(result.ok).toBe(false);
  365. if (result.ok) return;
  366. expect(result.error.field).toBe("gemini_google_search_preference");
  367. });
  368. });
  369. describe("anthropic_max_tokens_preference", () => {
  370. it("accepts inherit", () => {
  371. const result = prepareProviderBatchApplyUpdates({
  372. anthropic_max_tokens_preference: { set: "inherit" },
  373. });
  374. expect(result.ok).toBe(true);
  375. if (!result.ok) return;
  376. expect(result.data.anthropic_max_tokens_preference).toBe("inherit");
  377. });
  378. it("accepts positive numeric string", () => {
  379. const result = prepareProviderBatchApplyUpdates({
  380. anthropic_max_tokens_preference: { set: "8192" },
  381. });
  382. expect(result.ok).toBe(true);
  383. if (!result.ok) return;
  384. expect(result.data.anthropic_max_tokens_preference).toBe("8192");
  385. });
  386. it("accepts small positive numeric string (no range restriction)", () => {
  387. const result = prepareProviderBatchApplyUpdates({
  388. anthropic_max_tokens_preference: { set: "1" },
  389. });
  390. expect(result.ok).toBe(true);
  391. if (!result.ok) return;
  392. expect(result.data.anthropic_max_tokens_preference).toBe("1");
  393. });
  394. it("rejects non-numeric string", () => {
  395. const result = normalizeProviderBatchPatchDraft({
  396. anthropic_max_tokens_preference: { set: "abc" } as never,
  397. });
  398. expect(result.ok).toBe(false);
  399. if (result.ok) return;
  400. expect(result.error.field).toBe("anthropic_max_tokens_preference");
  401. });
  402. it("rejects zero", () => {
  403. const result = normalizeProviderBatchPatchDraft({
  404. anthropic_max_tokens_preference: { set: "0" } as never,
  405. });
  406. expect(result.ok).toBe(false);
  407. if (result.ok) return;
  408. expect(result.error.field).toBe("anthropic_max_tokens_preference");
  409. });
  410. it("clears to inherit", () => {
  411. const result = prepareProviderBatchApplyUpdates({
  412. anthropic_max_tokens_preference: { clear: true },
  413. });
  414. expect(result.ok).toBe(true);
  415. if (!result.ok) return;
  416. expect(result.data.anthropic_max_tokens_preference).toBe("inherit");
  417. });
  418. });
  419. describe("rate limit fields", () => {
  420. it.each([
  421. "limit_5h_usd",
  422. "limit_daily_usd",
  423. "limit_weekly_usd",
  424. "limit_monthly_usd",
  425. "limit_total_usd",
  426. ] as const)("accepts number set and clears to null for %s", (field) => {
  427. const setResult = prepareProviderBatchApplyUpdates({
  428. [field]: { set: 100.5 },
  429. });
  430. expect(setResult.ok).toBe(true);
  431. if (!setResult.ok) return;
  432. expect(setResult.data[field]).toBe(100.5);
  433. const clearResult = prepareProviderBatchApplyUpdates({
  434. [field]: { clear: true },
  435. });
  436. expect(clearResult.ok).toBe(true);
  437. if (!clearResult.ok) return;
  438. expect(clearResult.data[field]).toBeNull();
  439. });
  440. it("rejects non-number for limit_5h_usd", () => {
  441. const result = normalizeProviderBatchPatchDraft({
  442. limit_5h_usd: { set: "100" } as never,
  443. });
  444. expect(result.ok).toBe(false);
  445. if (result.ok) return;
  446. expect(result.error.field).toBe("limit_5h_usd");
  447. });
  448. it("rejects NaN for number fields", () => {
  449. const result = normalizeProviderBatchPatchDraft({
  450. limit_daily_usd: { set: Number.NaN } as never,
  451. });
  452. expect(result.ok).toBe(false);
  453. if (result.ok) return;
  454. expect(result.error.field).toBe("limit_daily_usd");
  455. });
  456. it("rejects Infinity for number fields", () => {
  457. const result = normalizeProviderBatchPatchDraft({
  458. limit_weekly_usd: { set: Number.POSITIVE_INFINITY } as never,
  459. });
  460. expect(result.ok).toBe(false);
  461. if (result.ok) return;
  462. expect(result.error.field).toBe("limit_weekly_usd");
  463. });
  464. it("accepts limit_concurrent_sessions as number (non-clearable)", () => {
  465. const result = prepareProviderBatchApplyUpdates({
  466. limit_concurrent_sessions: { set: 5 },
  467. });
  468. expect(result.ok).toBe(true);
  469. if (!result.ok) return;
  470. expect(result.data.limit_concurrent_sessions).toBe(5);
  471. });
  472. it("rejects clear on limit_concurrent_sessions", () => {
  473. const result = normalizeProviderBatchPatchDraft({
  474. limit_concurrent_sessions: { clear: true } as never,
  475. });
  476. expect(result.ok).toBe(false);
  477. if (result.ok) return;
  478. expect(result.error.field).toBe("limit_concurrent_sessions");
  479. });
  480. it.each(["fixed", "rolling"] as const)("accepts daily_reset_mode value: %s", (value) => {
  481. const result = prepareProviderBatchApplyUpdates({
  482. daily_reset_mode: { set: value },
  483. });
  484. expect(result.ok).toBe(true);
  485. if (!result.ok) return;
  486. expect(result.data.daily_reset_mode).toBe(value);
  487. });
  488. it("rejects invalid daily_reset_mode value", () => {
  489. const result = normalizeProviderBatchPatchDraft({
  490. daily_reset_mode: { set: "hourly" } as never,
  491. });
  492. expect(result.ok).toBe(false);
  493. if (result.ok) return;
  494. expect(result.error.field).toBe("daily_reset_mode");
  495. });
  496. it("rejects clear on daily_reset_mode", () => {
  497. const result = normalizeProviderBatchPatchDraft({
  498. daily_reset_mode: { clear: true } as never,
  499. });
  500. expect(result.ok).toBe(false);
  501. if (result.ok) return;
  502. expect(result.error.field).toBe("daily_reset_mode");
  503. });
  504. it("accepts daily_reset_time as string (non-clearable)", () => {
  505. const result = prepareProviderBatchApplyUpdates({
  506. daily_reset_time: { set: "00:00" },
  507. });
  508. expect(result.ok).toBe(true);
  509. if (!result.ok) return;
  510. expect(result.data.daily_reset_time).toBe("00:00");
  511. });
  512. it("rejects clear on daily_reset_time", () => {
  513. const result = normalizeProviderBatchPatchDraft({
  514. daily_reset_time: { clear: true } as never,
  515. });
  516. expect(result.ok).toBe(false);
  517. if (result.ok) return;
  518. expect(result.error.field).toBe("daily_reset_time");
  519. });
  520. });
  521. describe("circuit breaker fields", () => {
  522. it.each([
  523. "circuit_breaker_failure_threshold",
  524. "circuit_breaker_open_duration",
  525. "circuit_breaker_half_open_success_threshold",
  526. ] as const)("accepts number set for %s (non-clearable)", (field) => {
  527. const result = prepareProviderBatchApplyUpdates({
  528. [field]: { set: 10 },
  529. });
  530. expect(result.ok).toBe(true);
  531. if (!result.ok) return;
  532. expect(result.data[field]).toBe(10);
  533. });
  534. it.each([
  535. "circuit_breaker_failure_threshold",
  536. "circuit_breaker_open_duration",
  537. "circuit_breaker_half_open_success_threshold",
  538. ] as const)("rejects clear on %s", (field) => {
  539. const result = normalizeProviderBatchPatchDraft({
  540. [field]: { clear: true } as never,
  541. });
  542. expect(result.ok).toBe(false);
  543. if (result.ok) return;
  544. expect(result.error.field).toBe(field);
  545. });
  546. it("accepts max_retry_attempts and clears to null", () => {
  547. const setResult = prepareProviderBatchApplyUpdates({
  548. max_retry_attempts: { set: 3 },
  549. });
  550. expect(setResult.ok).toBe(true);
  551. if (!setResult.ok) return;
  552. expect(setResult.data.max_retry_attempts).toBe(3);
  553. const clearResult = prepareProviderBatchApplyUpdates({
  554. max_retry_attempts: { clear: true },
  555. });
  556. expect(clearResult.ok).toBe(true);
  557. if (!clearResult.ok) return;
  558. expect(clearResult.data.max_retry_attempts).toBeNull();
  559. });
  560. });
  561. describe("network fields", () => {
  562. it("accepts proxy_url as string and clears to null", () => {
  563. const setResult = prepareProviderBatchApplyUpdates({
  564. proxy_url: { set: "socks5://proxy.example.com:1080" },
  565. });
  566. expect(setResult.ok).toBe(true);
  567. if (!setResult.ok) return;
  568. expect(setResult.data.proxy_url).toBe("socks5://proxy.example.com:1080");
  569. const clearResult = prepareProviderBatchApplyUpdates({
  570. proxy_url: { clear: true },
  571. });
  572. expect(clearResult.ok).toBe(true);
  573. if (!clearResult.ok) return;
  574. expect(clearResult.data.proxy_url).toBeNull();
  575. });
  576. it("accepts boolean set for proxy_fallback_to_direct (non-clearable)", () => {
  577. const result = prepareProviderBatchApplyUpdates({
  578. proxy_fallback_to_direct: { set: true },
  579. });
  580. expect(result.ok).toBe(true);
  581. if (!result.ok) return;
  582. expect(result.data.proxy_fallback_to_direct).toBe(true);
  583. });
  584. it("rejects clear on proxy_fallback_to_direct", () => {
  585. const result = normalizeProviderBatchPatchDraft({
  586. proxy_fallback_to_direct: { clear: true } as never,
  587. });
  588. expect(result.ok).toBe(false);
  589. if (result.ok) return;
  590. expect(result.error.field).toBe("proxy_fallback_to_direct");
  591. });
  592. it.each([
  593. "first_byte_timeout_streaming_ms",
  594. "streaming_idle_timeout_ms",
  595. "request_timeout_non_streaming_ms",
  596. ] as const)("accepts number set for %s (non-clearable)", (field) => {
  597. const result = prepareProviderBatchApplyUpdates({
  598. [field]: { set: 30000 },
  599. });
  600. expect(result.ok).toBe(true);
  601. if (!result.ok) return;
  602. expect(result.data[field]).toBe(30000);
  603. });
  604. it.each([
  605. "first_byte_timeout_streaming_ms",
  606. "streaming_idle_timeout_ms",
  607. "request_timeout_non_streaming_ms",
  608. ] as const)("rejects clear on %s", (field) => {
  609. const result = normalizeProviderBatchPatchDraft({
  610. [field]: { clear: true } as never,
  611. });
  612. expect(result.ok).toBe(false);
  613. if (result.ok) return;
  614. expect(result.error.field).toBe(field);
  615. });
  616. });
  617. describe("MCP fields", () => {
  618. it.each([
  619. "none",
  620. "minimax",
  621. "glm",
  622. "custom",
  623. ] as const)("accepts mcp_passthrough_type value: %s", (value) => {
  624. const result = prepareProviderBatchApplyUpdates({
  625. mcp_passthrough_type: { set: value },
  626. });
  627. expect(result.ok).toBe(true);
  628. if (!result.ok) return;
  629. expect(result.data.mcp_passthrough_type).toBe(value);
  630. });
  631. it("rejects invalid mcp_passthrough_type value", () => {
  632. const result = normalizeProviderBatchPatchDraft({
  633. mcp_passthrough_type: { set: "openai" } as never,
  634. });
  635. expect(result.ok).toBe(false);
  636. if (result.ok) return;
  637. expect(result.error.field).toBe("mcp_passthrough_type");
  638. });
  639. it("rejects clear on mcp_passthrough_type", () => {
  640. const result = normalizeProviderBatchPatchDraft({
  641. mcp_passthrough_type: { clear: true } as never,
  642. });
  643. expect(result.ok).toBe(false);
  644. if (result.ok) return;
  645. expect(result.error.field).toBe("mcp_passthrough_type");
  646. });
  647. it("accepts mcp_passthrough_url as string and clears to null", () => {
  648. const setResult = prepareProviderBatchApplyUpdates({
  649. mcp_passthrough_url: { set: "https://api.minimaxi.com" },
  650. });
  651. expect(setResult.ok).toBe(true);
  652. if (!setResult.ok) return;
  653. expect(setResult.data.mcp_passthrough_url).toBe("https://api.minimaxi.com");
  654. const clearResult = prepareProviderBatchApplyUpdates({
  655. mcp_passthrough_url: { clear: true },
  656. });
  657. expect(clearResult.ok).toBe(true);
  658. if (!clearResult.ok) return;
  659. expect(clearResult.data.mcp_passthrough_url).toBeNull();
  660. });
  661. });
  662. describe("preference fields clear to inherit", () => {
  663. it.each([
  664. "cache_ttl_preference",
  665. "context_1m_preference",
  666. "codex_reasoning_effort_preference",
  667. "codex_reasoning_summary_preference",
  668. "codex_text_verbosity_preference",
  669. "codex_parallel_tool_calls_preference",
  670. "anthropic_max_tokens_preference",
  671. "gemini_google_search_preference",
  672. ] as const)("clears %s to inherit", (field) => {
  673. const result = prepareProviderBatchApplyUpdates({
  674. [field]: { clear: true },
  675. });
  676. expect(result.ok).toBe(true);
  677. if (!result.ok) return;
  678. expect(result.data[field]).toBe("inherit");
  679. });
  680. });
  681. describe("non-clearable field rejection", () => {
  682. it.each([
  683. "preserve_client_ip",
  684. "swap_cache_ttl_billing",
  685. "daily_reset_mode",
  686. "daily_reset_time",
  687. "limit_concurrent_sessions",
  688. "circuit_breaker_failure_threshold",
  689. "circuit_breaker_open_duration",
  690. "circuit_breaker_half_open_success_threshold",
  691. "proxy_fallback_to_direct",
  692. "first_byte_timeout_streaming_ms",
  693. "streaming_idle_timeout_ms",
  694. "request_timeout_non_streaming_ms",
  695. "mcp_passthrough_type",
  696. ] as const)("rejects clear on non-clearable field: %s", (field) => {
  697. const result = normalizeProviderBatchPatchDraft({
  698. [field]: { clear: true } as never,
  699. });
  700. expect(result.ok).toBe(false);
  701. if (result.ok) return;
  702. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  703. expect(result.error.field).toBe(field);
  704. });
  705. });
  706. describe("hasProviderBatchPatchChanges for new fields", () => {
  707. it("detects change on a single new field", () => {
  708. const normalized = normalizeProviderBatchPatchDraft({
  709. preserve_client_ip: { set: true },
  710. });
  711. expect(normalized.ok).toBe(true);
  712. if (!normalized.ok) return;
  713. expect(hasProviderBatchPatchChanges(normalized.data)).toBe(true);
  714. });
  715. it("detects change on mcp_passthrough_url (last field)", () => {
  716. const normalized = normalizeProviderBatchPatchDraft({
  717. mcp_passthrough_url: { set: "https://example.com" },
  718. });
  719. expect(normalized.ok).toBe(true);
  720. if (!normalized.ok) return;
  721. expect(hasProviderBatchPatchChanges(normalized.data)).toBe(true);
  722. });
  723. it("reports no change when all new fields are no_change", () => {
  724. const normalized = normalizeProviderBatchPatchDraft({
  725. preserve_client_ip: { no_change: true },
  726. limit_5h_usd: { no_change: true },
  727. proxy_url: { no_change: true },
  728. });
  729. expect(normalized.ok).toBe(true);
  730. if (!normalized.ok) return;
  731. expect(hasProviderBatchPatchChanges(normalized.data)).toBe(false);
  732. });
  733. it("detects change on active_time_start", () => {
  734. const normalized = normalizeProviderBatchPatchDraft({
  735. active_time_start: { set: "09:00" },
  736. });
  737. expect(normalized.ok).toBe(true);
  738. if (!normalized.ok) return;
  739. expect(hasProviderBatchPatchChanges(normalized.data)).toBe(true);
  740. });
  741. it("detects change on active_time_end", () => {
  742. const normalized = normalizeProviderBatchPatchDraft({
  743. active_time_end: { set: "17:00" },
  744. });
  745. expect(normalized.ok).toBe(true);
  746. if (!normalized.ok) return;
  747. expect(hasProviderBatchPatchChanges(normalized.data)).toBe(true);
  748. });
  749. });
  750. describe("active_time_start / active_time_end batch patch", () => {
  751. it("accepts active_time_start as string and maps to apply payload", () => {
  752. const result = prepareProviderBatchApplyUpdates({
  753. active_time_start: { set: "09:00" },
  754. });
  755. expect(result.ok).toBe(true);
  756. if (!result.ok) return;
  757. expect(result.data.active_time_start).toBe("09:00");
  758. });
  759. it("clears active_time_start to null", () => {
  760. const result = prepareProviderBatchApplyUpdates({
  761. active_time_start: { clear: true },
  762. });
  763. expect(result.ok).toBe(true);
  764. if (!result.ok) return;
  765. expect(result.data.active_time_start).toBeNull();
  766. });
  767. it("accepts active_time_end as string and maps to apply payload", () => {
  768. const result = prepareProviderBatchApplyUpdates({
  769. active_time_end: { set: "17:00" },
  770. });
  771. expect(result.ok).toBe(true);
  772. if (!result.ok) return;
  773. expect(result.data.active_time_end).toBe("17:00");
  774. });
  775. it("clears active_time_end to null", () => {
  776. const result = prepareProviderBatchApplyUpdates({
  777. active_time_end: { clear: true },
  778. });
  779. expect(result.ok).toBe(true);
  780. if (!result.ok) return;
  781. expect(result.data.active_time_end).toBeNull();
  782. });
  783. it("rejects non-string value for active_time_start", () => {
  784. const result = normalizeProviderBatchPatchDraft({
  785. active_time_start: { set: 900 } as never,
  786. });
  787. expect(result.ok).toBe(false);
  788. if (result.ok) return;
  789. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  790. expect(result.error.field).toBe("active_time_start");
  791. });
  792. it("rejects non-string value for active_time_end", () => {
  793. const result = normalizeProviderBatchPatchDraft({
  794. active_time_end: { set: 900 } as never,
  795. });
  796. expect(result.ok).toBe(false);
  797. if (result.ok) return;
  798. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  799. expect(result.error.field).toBe("active_time_end");
  800. });
  801. it("rejects invalid HH:mm format for active_time_start", () => {
  802. const result = normalizeProviderBatchPatchDraft({
  803. active_time_start: { set: "9:00" },
  804. });
  805. expect(result.ok).toBe(false);
  806. if (result.ok) return;
  807. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  808. expect(result.error.field).toBe("active_time_start");
  809. });
  810. it("rejects out-of-range time for active_time_end", () => {
  811. const result = normalizeProviderBatchPatchDraft({
  812. active_time_end: { set: "25:00" },
  813. });
  814. expect(result.ok).toBe(false);
  815. if (result.ok) return;
  816. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  817. expect(result.error.field).toBe("active_time_end");
  818. });
  819. });
  820. describe("combined set across all categories", () => {
  821. it("handles a batch patch touching all field categories at once", () => {
  822. const result = prepareProviderBatchApplyUpdates({
  823. // existing
  824. is_enabled: { set: true },
  825. group_tag: { set: "batch-test" },
  826. // routing
  827. preserve_client_ip: { set: false },
  828. cache_ttl_preference: { set: "1h" },
  829. codex_reasoning_effort_preference: { set: "high" },
  830. anthropic_max_tokens_preference: { set: "16384" },
  831. // rate limit
  832. limit_5h_usd: { set: 50 },
  833. daily_reset_mode: { set: "rolling" },
  834. daily_reset_time: { set: "08:00" },
  835. // circuit breaker
  836. circuit_breaker_failure_threshold: { set: 5 },
  837. max_retry_attempts: { set: 2 },
  838. // network
  839. proxy_url: { set: "https://proxy.local" },
  840. proxy_fallback_to_direct: { set: true },
  841. first_byte_timeout_streaming_ms: { set: 15000 },
  842. // mcp
  843. mcp_passthrough_type: { set: "minimax" },
  844. mcp_passthrough_url: { set: "https://api.minimaxi.com" },
  845. // schedule
  846. active_time_start: { set: "09:00" },
  847. active_time_end: { set: "17:00" },
  848. });
  849. expect(result.ok).toBe(true);
  850. if (!result.ok) return;
  851. expect(result.data.is_enabled).toBe(true);
  852. expect(result.data.group_tag).toBe("batch-test");
  853. expect(result.data.preserve_client_ip).toBe(false);
  854. expect(result.data.cache_ttl_preference).toBe("1h");
  855. expect(result.data.codex_reasoning_effort_preference).toBe("high");
  856. expect(result.data.anthropic_max_tokens_preference).toBe("16384");
  857. expect(result.data.limit_5h_usd).toBe(50);
  858. expect(result.data.daily_reset_mode).toBe("rolling");
  859. expect(result.data.daily_reset_time).toBe("08:00");
  860. expect(result.data.circuit_breaker_failure_threshold).toBe(5);
  861. expect(result.data.max_retry_attempts).toBe(2);
  862. expect(result.data.proxy_url).toBe("https://proxy.local");
  863. expect(result.data.proxy_fallback_to_direct).toBe(true);
  864. expect(result.data.first_byte_timeout_streaming_ms).toBe(15000);
  865. expect(result.data.mcp_passthrough_type).toBe("minimax");
  866. expect(result.data.mcp_passthrough_url).toBe("https://api.minimaxi.com");
  867. expect(result.data.active_time_start).toBe("09:00");
  868. expect(result.data.active_time_end).toBe("17:00");
  869. });
  870. });
  871. });