providers-patch-contract.test.ts 32 KB

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