providers-patch-contract.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922
  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("rejects invalid thinking budget string values", () => {
  101. const result = normalizeProviderBatchPatchDraft({
  102. anthropic_thinking_budget_preference: {
  103. set: "abc",
  104. } as never,
  105. });
  106. expect(result.ok).toBe(false);
  107. if (result.ok) return;
  108. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  109. expect(result.error.field).toBe("anthropic_thinking_budget_preference");
  110. });
  111. it("rejects adaptive thinking specific mode with empty models", () => {
  112. const result = normalizeProviderBatchPatchDraft({
  113. anthropic_adaptive_thinking: {
  114. set: {
  115. effort: "high",
  116. modelMatchMode: "specific",
  117. models: [],
  118. },
  119. },
  120. });
  121. expect(result.ok).toBe(false);
  122. if (result.ok) return;
  123. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  124. expect(result.error.field).toBe("anthropic_adaptive_thinking");
  125. });
  126. it("supports explicit no_change mode", () => {
  127. const result = normalizeProviderBatchPatchDraft({
  128. model_redirects: { no_change: true },
  129. });
  130. expect(result.ok).toBe(true);
  131. if (!result.ok) return;
  132. expect(result.data.model_redirects.mode).toBe("no_change");
  133. });
  134. it("rejects unknown top-level fields", () => {
  135. const result = normalizeProviderBatchPatchDraft({
  136. unknown_field: { set: 1 },
  137. } as never);
  138. expect(result.ok).toBe(false);
  139. if (result.ok) return;
  140. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  141. expect(result.error.field).toBe("__root__");
  142. });
  143. it("rejects non-object draft payloads", () => {
  144. const result = normalizeProviderBatchPatchDraft(null as never);
  145. expect(result.ok).toBe(false);
  146. if (result.ok) return;
  147. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  148. expect(result.error.field).toBe("__root__");
  149. });
  150. describe("routing fields", () => {
  151. it("accepts boolean set for preserve_client_ip and swap_cache_ttl_billing", () => {
  152. const result = prepareProviderBatchApplyUpdates({
  153. preserve_client_ip: { set: true },
  154. swap_cache_ttl_billing: { set: false },
  155. });
  156. expect(result.ok).toBe(true);
  157. if (!result.ok) return;
  158. expect(result.data.preserve_client_ip).toBe(true);
  159. expect(result.data.swap_cache_ttl_billing).toBe(false);
  160. });
  161. it("accepts group_priorities as Record<string, number>", () => {
  162. const result = prepareProviderBatchApplyUpdates({
  163. group_priorities: { set: { us: 10, eu: 5 } },
  164. });
  165. expect(result.ok).toBe(true);
  166. if (!result.ok) return;
  167. expect(result.data.group_priorities).toEqual({ us: 10, eu: 5 });
  168. });
  169. it("rejects group_priorities with non-number values", () => {
  170. const result = normalizeProviderBatchPatchDraft({
  171. group_priorities: { set: { us: "high" } } as never,
  172. });
  173. expect(result.ok).toBe(false);
  174. if (result.ok) return;
  175. expect(result.error.field).toBe("group_priorities");
  176. });
  177. it("rejects group_priorities when array", () => {
  178. const result = normalizeProviderBatchPatchDraft({
  179. group_priorities: { set: [1, 2, 3] } as never,
  180. });
  181. expect(result.ok).toBe(false);
  182. if (result.ok) return;
  183. expect(result.error.field).toBe("group_priorities");
  184. });
  185. it("clears group_priorities to null", () => {
  186. const result = prepareProviderBatchApplyUpdates({
  187. group_priorities: { clear: true },
  188. });
  189. expect(result.ok).toBe(true);
  190. if (!result.ok) return;
  191. expect(result.data.group_priorities).toBeNull();
  192. });
  193. it.each([
  194. ["cache_ttl_preference", "inherit"],
  195. ["cache_ttl_preference", "5m"],
  196. ["cache_ttl_preference", "1h"],
  197. ] as const)("accepts valid %s value: %s", (field, value) => {
  198. const result = prepareProviderBatchApplyUpdates({
  199. [field]: { set: value },
  200. });
  201. expect(result.ok).toBe(true);
  202. if (!result.ok) return;
  203. expect(result.data[field]).toBe(value);
  204. });
  205. it("rejects invalid cache_ttl_preference value", () => {
  206. const result = normalizeProviderBatchPatchDraft({
  207. cache_ttl_preference: { set: "30m" } as never,
  208. });
  209. expect(result.ok).toBe(false);
  210. if (result.ok) return;
  211. expect(result.error.field).toBe("cache_ttl_preference");
  212. });
  213. it.each([
  214. ["context_1m_preference", "inherit"],
  215. ["context_1m_preference", "force_enable"],
  216. ["context_1m_preference", "disabled"],
  217. ] as const)("accepts valid %s value: %s", (field, value) => {
  218. const result = prepareProviderBatchApplyUpdates({
  219. [field]: { set: value },
  220. });
  221. expect(result.ok).toBe(true);
  222. if (!result.ok) return;
  223. expect(result.data[field]).toBe(value);
  224. });
  225. it.each([
  226. ["codex_reasoning_effort_preference", "inherit"],
  227. ["codex_reasoning_effort_preference", "none"],
  228. ["codex_reasoning_effort_preference", "minimal"],
  229. ["codex_reasoning_effort_preference", "low"],
  230. ["codex_reasoning_effort_preference", "medium"],
  231. ["codex_reasoning_effort_preference", "high"],
  232. ["codex_reasoning_effort_preference", "xhigh"],
  233. ] as const)("accepts valid %s value: %s", (field, value) => {
  234. const result = prepareProviderBatchApplyUpdates({
  235. [field]: { set: value },
  236. });
  237. expect(result.ok).toBe(true);
  238. if (!result.ok) return;
  239. expect(result.data[field]).toBe(value);
  240. });
  241. it("rejects invalid codex_reasoning_effort_preference value", () => {
  242. const result = normalizeProviderBatchPatchDraft({
  243. codex_reasoning_effort_preference: { set: "ultra" } as never,
  244. });
  245. expect(result.ok).toBe(false);
  246. if (result.ok) return;
  247. expect(result.error.field).toBe("codex_reasoning_effort_preference");
  248. });
  249. it.each([
  250. ["codex_reasoning_summary_preference", "inherit"],
  251. ["codex_reasoning_summary_preference", "auto"],
  252. ["codex_reasoning_summary_preference", "detailed"],
  253. ] as const)("accepts valid %s value: %s", (field, value) => {
  254. const result = prepareProviderBatchApplyUpdates({
  255. [field]: { set: value },
  256. });
  257. expect(result.ok).toBe(true);
  258. if (!result.ok) return;
  259. expect(result.data[field]).toBe(value);
  260. });
  261. it.each([
  262. ["codex_text_verbosity_preference", "inherit"],
  263. ["codex_text_verbosity_preference", "low"],
  264. ["codex_text_verbosity_preference", "medium"],
  265. ["codex_text_verbosity_preference", "high"],
  266. ] as const)("accepts valid %s value: %s", (field, value) => {
  267. const result = prepareProviderBatchApplyUpdates({
  268. [field]: { set: value },
  269. });
  270. expect(result.ok).toBe(true);
  271. if (!result.ok) return;
  272. expect(result.data[field]).toBe(value);
  273. });
  274. it.each([
  275. ["codex_parallel_tool_calls_preference", "inherit"],
  276. ["codex_parallel_tool_calls_preference", "true"],
  277. ["codex_parallel_tool_calls_preference", "false"],
  278. ] as const)("accepts valid %s value: %s", (field, value) => {
  279. const result = prepareProviderBatchApplyUpdates({
  280. [field]: { set: value },
  281. });
  282. expect(result.ok).toBe(true);
  283. if (!result.ok) return;
  284. expect(result.data[field]).toBe(value);
  285. });
  286. it.each([
  287. ["gemini_google_search_preference", "inherit"],
  288. ["gemini_google_search_preference", "enabled"],
  289. ["gemini_google_search_preference", "disabled"],
  290. ] as const)("accepts valid %s value: %s", (field, value) => {
  291. const result = prepareProviderBatchApplyUpdates({
  292. [field]: { set: value },
  293. });
  294. expect(result.ok).toBe(true);
  295. if (!result.ok) return;
  296. expect(result.data[field]).toBe(value);
  297. });
  298. it("rejects invalid gemini_google_search_preference value", () => {
  299. const result = normalizeProviderBatchPatchDraft({
  300. gemini_google_search_preference: { set: "auto" } as never,
  301. });
  302. expect(result.ok).toBe(false);
  303. if (result.ok) return;
  304. expect(result.error.field).toBe("gemini_google_search_preference");
  305. });
  306. });
  307. describe("anthropic_max_tokens_preference", () => {
  308. it("accepts inherit", () => {
  309. const result = prepareProviderBatchApplyUpdates({
  310. anthropic_max_tokens_preference: { set: "inherit" },
  311. });
  312. expect(result.ok).toBe(true);
  313. if (!result.ok) return;
  314. expect(result.data.anthropic_max_tokens_preference).toBe("inherit");
  315. });
  316. it("accepts positive numeric string", () => {
  317. const result = prepareProviderBatchApplyUpdates({
  318. anthropic_max_tokens_preference: { set: "8192" },
  319. });
  320. expect(result.ok).toBe(true);
  321. if (!result.ok) return;
  322. expect(result.data.anthropic_max_tokens_preference).toBe("8192");
  323. });
  324. it("accepts small positive numeric string (no range restriction)", () => {
  325. const result = prepareProviderBatchApplyUpdates({
  326. anthropic_max_tokens_preference: { set: "1" },
  327. });
  328. expect(result.ok).toBe(true);
  329. if (!result.ok) return;
  330. expect(result.data.anthropic_max_tokens_preference).toBe("1");
  331. });
  332. it("rejects non-numeric string", () => {
  333. const result = normalizeProviderBatchPatchDraft({
  334. anthropic_max_tokens_preference: { set: "abc" } as never,
  335. });
  336. expect(result.ok).toBe(false);
  337. if (result.ok) return;
  338. expect(result.error.field).toBe("anthropic_max_tokens_preference");
  339. });
  340. it("rejects zero", () => {
  341. const result = normalizeProviderBatchPatchDraft({
  342. anthropic_max_tokens_preference: { set: "0" } as never,
  343. });
  344. expect(result.ok).toBe(false);
  345. if (result.ok) return;
  346. expect(result.error.field).toBe("anthropic_max_tokens_preference");
  347. });
  348. it("clears to inherit", () => {
  349. const result = prepareProviderBatchApplyUpdates({
  350. anthropic_max_tokens_preference: { clear: true },
  351. });
  352. expect(result.ok).toBe(true);
  353. if (!result.ok) return;
  354. expect(result.data.anthropic_max_tokens_preference).toBe("inherit");
  355. });
  356. });
  357. describe("rate limit fields", () => {
  358. it.each([
  359. "limit_5h_usd",
  360. "limit_daily_usd",
  361. "limit_weekly_usd",
  362. "limit_monthly_usd",
  363. "limit_total_usd",
  364. ] as const)("accepts number set and clears to null for %s", (field) => {
  365. const setResult = prepareProviderBatchApplyUpdates({
  366. [field]: { set: 100.5 },
  367. });
  368. expect(setResult.ok).toBe(true);
  369. if (!setResult.ok) return;
  370. expect(setResult.data[field]).toBe(100.5);
  371. const clearResult = prepareProviderBatchApplyUpdates({
  372. [field]: { clear: true },
  373. });
  374. expect(clearResult.ok).toBe(true);
  375. if (!clearResult.ok) return;
  376. expect(clearResult.data[field]).toBeNull();
  377. });
  378. it("rejects non-number for limit_5h_usd", () => {
  379. const result = normalizeProviderBatchPatchDraft({
  380. limit_5h_usd: { set: "100" } as never,
  381. });
  382. expect(result.ok).toBe(false);
  383. if (result.ok) return;
  384. expect(result.error.field).toBe("limit_5h_usd");
  385. });
  386. it("rejects NaN for number fields", () => {
  387. const result = normalizeProviderBatchPatchDraft({
  388. limit_daily_usd: { set: Number.NaN } as never,
  389. });
  390. expect(result.ok).toBe(false);
  391. if (result.ok) return;
  392. expect(result.error.field).toBe("limit_daily_usd");
  393. });
  394. it("rejects Infinity for number fields", () => {
  395. const result = normalizeProviderBatchPatchDraft({
  396. limit_weekly_usd: { set: Number.POSITIVE_INFINITY } as never,
  397. });
  398. expect(result.ok).toBe(false);
  399. if (result.ok) return;
  400. expect(result.error.field).toBe("limit_weekly_usd");
  401. });
  402. it("accepts limit_concurrent_sessions as number (non-clearable)", () => {
  403. const result = prepareProviderBatchApplyUpdates({
  404. limit_concurrent_sessions: { set: 5 },
  405. });
  406. expect(result.ok).toBe(true);
  407. if (!result.ok) return;
  408. expect(result.data.limit_concurrent_sessions).toBe(5);
  409. });
  410. it("rejects clear on limit_concurrent_sessions", () => {
  411. const result = normalizeProviderBatchPatchDraft({
  412. limit_concurrent_sessions: { clear: true } as never,
  413. });
  414. expect(result.ok).toBe(false);
  415. if (result.ok) return;
  416. expect(result.error.field).toBe("limit_concurrent_sessions");
  417. });
  418. it.each(["fixed", "rolling"] as const)("accepts daily_reset_mode value: %s", (value) => {
  419. const result = prepareProviderBatchApplyUpdates({
  420. daily_reset_mode: { set: value },
  421. });
  422. expect(result.ok).toBe(true);
  423. if (!result.ok) return;
  424. expect(result.data.daily_reset_mode).toBe(value);
  425. });
  426. it("rejects invalid daily_reset_mode value", () => {
  427. const result = normalizeProviderBatchPatchDraft({
  428. daily_reset_mode: { set: "hourly" } as never,
  429. });
  430. expect(result.ok).toBe(false);
  431. if (result.ok) return;
  432. expect(result.error.field).toBe("daily_reset_mode");
  433. });
  434. it("rejects clear on daily_reset_mode", () => {
  435. const result = normalizeProviderBatchPatchDraft({
  436. daily_reset_mode: { clear: true } as never,
  437. });
  438. expect(result.ok).toBe(false);
  439. if (result.ok) return;
  440. expect(result.error.field).toBe("daily_reset_mode");
  441. });
  442. it("accepts daily_reset_time as string (non-clearable)", () => {
  443. const result = prepareProviderBatchApplyUpdates({
  444. daily_reset_time: { set: "00:00" },
  445. });
  446. expect(result.ok).toBe(true);
  447. if (!result.ok) return;
  448. expect(result.data.daily_reset_time).toBe("00:00");
  449. });
  450. it("rejects clear on daily_reset_time", () => {
  451. const result = normalizeProviderBatchPatchDraft({
  452. daily_reset_time: { clear: true } as never,
  453. });
  454. expect(result.ok).toBe(false);
  455. if (result.ok) return;
  456. expect(result.error.field).toBe("daily_reset_time");
  457. });
  458. });
  459. describe("circuit breaker fields", () => {
  460. it.each([
  461. "circuit_breaker_failure_threshold",
  462. "circuit_breaker_open_duration",
  463. "circuit_breaker_half_open_success_threshold",
  464. ] as const)("accepts number set for %s (non-clearable)", (field) => {
  465. const result = prepareProviderBatchApplyUpdates({
  466. [field]: { set: 10 },
  467. });
  468. expect(result.ok).toBe(true);
  469. if (!result.ok) return;
  470. expect(result.data[field]).toBe(10);
  471. });
  472. it.each([
  473. "circuit_breaker_failure_threshold",
  474. "circuit_breaker_open_duration",
  475. "circuit_breaker_half_open_success_threshold",
  476. ] as const)("rejects clear on %s", (field) => {
  477. const result = normalizeProviderBatchPatchDraft({
  478. [field]: { clear: true } as never,
  479. });
  480. expect(result.ok).toBe(false);
  481. if (result.ok) return;
  482. expect(result.error.field).toBe(field);
  483. });
  484. it("accepts max_retry_attempts and clears to null", () => {
  485. const setResult = prepareProviderBatchApplyUpdates({
  486. max_retry_attempts: { set: 3 },
  487. });
  488. expect(setResult.ok).toBe(true);
  489. if (!setResult.ok) return;
  490. expect(setResult.data.max_retry_attempts).toBe(3);
  491. const clearResult = prepareProviderBatchApplyUpdates({
  492. max_retry_attempts: { clear: true },
  493. });
  494. expect(clearResult.ok).toBe(true);
  495. if (!clearResult.ok) return;
  496. expect(clearResult.data.max_retry_attempts).toBeNull();
  497. });
  498. });
  499. describe("network fields", () => {
  500. it("accepts proxy_url as string and clears to null", () => {
  501. const setResult = prepareProviderBatchApplyUpdates({
  502. proxy_url: { set: "socks5://proxy.example.com:1080" },
  503. });
  504. expect(setResult.ok).toBe(true);
  505. if (!setResult.ok) return;
  506. expect(setResult.data.proxy_url).toBe("socks5://proxy.example.com:1080");
  507. const clearResult = prepareProviderBatchApplyUpdates({
  508. proxy_url: { clear: true },
  509. });
  510. expect(clearResult.ok).toBe(true);
  511. if (!clearResult.ok) return;
  512. expect(clearResult.data.proxy_url).toBeNull();
  513. });
  514. it("accepts boolean set for proxy_fallback_to_direct (non-clearable)", () => {
  515. const result = prepareProviderBatchApplyUpdates({
  516. proxy_fallback_to_direct: { set: true },
  517. });
  518. expect(result.ok).toBe(true);
  519. if (!result.ok) return;
  520. expect(result.data.proxy_fallback_to_direct).toBe(true);
  521. });
  522. it("rejects clear on proxy_fallback_to_direct", () => {
  523. const result = normalizeProviderBatchPatchDraft({
  524. proxy_fallback_to_direct: { clear: true } as never,
  525. });
  526. expect(result.ok).toBe(false);
  527. if (result.ok) return;
  528. expect(result.error.field).toBe("proxy_fallback_to_direct");
  529. });
  530. it.each([
  531. "first_byte_timeout_streaming_ms",
  532. "streaming_idle_timeout_ms",
  533. "request_timeout_non_streaming_ms",
  534. ] as const)("accepts number set for %s (non-clearable)", (field) => {
  535. const result = prepareProviderBatchApplyUpdates({
  536. [field]: { set: 30000 },
  537. });
  538. expect(result.ok).toBe(true);
  539. if (!result.ok) return;
  540. expect(result.data[field]).toBe(30000);
  541. });
  542. it.each([
  543. "first_byte_timeout_streaming_ms",
  544. "streaming_idle_timeout_ms",
  545. "request_timeout_non_streaming_ms",
  546. ] as const)("rejects clear on %s", (field) => {
  547. const result = normalizeProviderBatchPatchDraft({
  548. [field]: { clear: true } as never,
  549. });
  550. expect(result.ok).toBe(false);
  551. if (result.ok) return;
  552. expect(result.error.field).toBe(field);
  553. });
  554. });
  555. describe("MCP fields", () => {
  556. it.each([
  557. "none",
  558. "minimax",
  559. "glm",
  560. "custom",
  561. ] as const)("accepts mcp_passthrough_type value: %s", (value) => {
  562. const result = prepareProviderBatchApplyUpdates({
  563. mcp_passthrough_type: { set: value },
  564. });
  565. expect(result.ok).toBe(true);
  566. if (!result.ok) return;
  567. expect(result.data.mcp_passthrough_type).toBe(value);
  568. });
  569. it("rejects invalid mcp_passthrough_type value", () => {
  570. const result = normalizeProviderBatchPatchDraft({
  571. mcp_passthrough_type: { set: "openai" } as never,
  572. });
  573. expect(result.ok).toBe(false);
  574. if (result.ok) return;
  575. expect(result.error.field).toBe("mcp_passthrough_type");
  576. });
  577. it("rejects clear on mcp_passthrough_type", () => {
  578. const result = normalizeProviderBatchPatchDraft({
  579. mcp_passthrough_type: { clear: true } as never,
  580. });
  581. expect(result.ok).toBe(false);
  582. if (result.ok) return;
  583. expect(result.error.field).toBe("mcp_passthrough_type");
  584. });
  585. it("accepts mcp_passthrough_url as string and clears to null", () => {
  586. const setResult = prepareProviderBatchApplyUpdates({
  587. mcp_passthrough_url: { set: "https://api.minimaxi.com" },
  588. });
  589. expect(setResult.ok).toBe(true);
  590. if (!setResult.ok) return;
  591. expect(setResult.data.mcp_passthrough_url).toBe("https://api.minimaxi.com");
  592. const clearResult = prepareProviderBatchApplyUpdates({
  593. mcp_passthrough_url: { clear: true },
  594. });
  595. expect(clearResult.ok).toBe(true);
  596. if (!clearResult.ok) return;
  597. expect(clearResult.data.mcp_passthrough_url).toBeNull();
  598. });
  599. });
  600. describe("preference fields clear to inherit", () => {
  601. it.each([
  602. "cache_ttl_preference",
  603. "context_1m_preference",
  604. "codex_reasoning_effort_preference",
  605. "codex_reasoning_summary_preference",
  606. "codex_text_verbosity_preference",
  607. "codex_parallel_tool_calls_preference",
  608. "anthropic_max_tokens_preference",
  609. "gemini_google_search_preference",
  610. ] as const)("clears %s to inherit", (field) => {
  611. const result = prepareProviderBatchApplyUpdates({
  612. [field]: { clear: true },
  613. });
  614. expect(result.ok).toBe(true);
  615. if (!result.ok) return;
  616. expect(result.data[field]).toBe("inherit");
  617. });
  618. });
  619. describe("non-clearable field rejection", () => {
  620. it.each([
  621. "preserve_client_ip",
  622. "swap_cache_ttl_billing",
  623. "daily_reset_mode",
  624. "daily_reset_time",
  625. "limit_concurrent_sessions",
  626. "circuit_breaker_failure_threshold",
  627. "circuit_breaker_open_duration",
  628. "circuit_breaker_half_open_success_threshold",
  629. "proxy_fallback_to_direct",
  630. "first_byte_timeout_streaming_ms",
  631. "streaming_idle_timeout_ms",
  632. "request_timeout_non_streaming_ms",
  633. "mcp_passthrough_type",
  634. ] as const)("rejects clear on non-clearable field: %s", (field) => {
  635. const result = normalizeProviderBatchPatchDraft({
  636. [field]: { clear: true } as never,
  637. });
  638. expect(result.ok).toBe(false);
  639. if (result.ok) return;
  640. expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE);
  641. expect(result.error.field).toBe(field);
  642. });
  643. });
  644. describe("hasProviderBatchPatchChanges for new fields", () => {
  645. it("detects change on a single new field", () => {
  646. const normalized = normalizeProviderBatchPatchDraft({
  647. preserve_client_ip: { set: true },
  648. });
  649. expect(normalized.ok).toBe(true);
  650. if (!normalized.ok) return;
  651. expect(hasProviderBatchPatchChanges(normalized.data)).toBe(true);
  652. });
  653. it("detects change on mcp_passthrough_url (last field)", () => {
  654. const normalized = normalizeProviderBatchPatchDraft({
  655. mcp_passthrough_url: { set: "https://example.com" },
  656. });
  657. expect(normalized.ok).toBe(true);
  658. if (!normalized.ok) return;
  659. expect(hasProviderBatchPatchChanges(normalized.data)).toBe(true);
  660. });
  661. it("reports no change when all new fields are no_change", () => {
  662. const normalized = normalizeProviderBatchPatchDraft({
  663. preserve_client_ip: { no_change: true },
  664. limit_5h_usd: { no_change: true },
  665. proxy_url: { no_change: true },
  666. });
  667. expect(normalized.ok).toBe(true);
  668. if (!normalized.ok) return;
  669. expect(hasProviderBatchPatchChanges(normalized.data)).toBe(false);
  670. });
  671. });
  672. describe("combined set across all categories", () => {
  673. it("handles a batch patch touching all field categories at once", () => {
  674. const result = prepareProviderBatchApplyUpdates({
  675. // existing
  676. is_enabled: { set: true },
  677. group_tag: { set: "batch-test" },
  678. // routing
  679. preserve_client_ip: { set: false },
  680. cache_ttl_preference: { set: "1h" },
  681. codex_reasoning_effort_preference: { set: "high" },
  682. anthropic_max_tokens_preference: { set: "16384" },
  683. // rate limit
  684. limit_5h_usd: { set: 50 },
  685. daily_reset_mode: { set: "rolling" },
  686. daily_reset_time: { set: "08:00" },
  687. // circuit breaker
  688. circuit_breaker_failure_threshold: { set: 5 },
  689. max_retry_attempts: { set: 2 },
  690. // network
  691. proxy_url: { set: "https://proxy.local" },
  692. proxy_fallback_to_direct: { set: true },
  693. first_byte_timeout_streaming_ms: { set: 15000 },
  694. // mcp
  695. mcp_passthrough_type: { set: "minimax" },
  696. mcp_passthrough_url: { set: "https://api.minimaxi.com" },
  697. });
  698. expect(result.ok).toBe(true);
  699. if (!result.ok) return;
  700. expect(result.data.is_enabled).toBe(true);
  701. expect(result.data.group_tag).toBe("batch-test");
  702. expect(result.data.preserve_client_ip).toBe(false);
  703. expect(result.data.cache_ttl_preference).toBe("1h");
  704. expect(result.data.codex_reasoning_effort_preference).toBe("high");
  705. expect(result.data.anthropic_max_tokens_preference).toBe("16384");
  706. expect(result.data.limit_5h_usd).toBe(50);
  707. expect(result.data.daily_reset_mode).toBe("rolling");
  708. expect(result.data.daily_reset_time).toBe("08:00");
  709. expect(result.data.circuit_breaker_failure_threshold).toBe(5);
  710. expect(result.data.max_retry_attempts).toBe(2);
  711. expect(result.data.proxy_url).toBe("https://proxy.local");
  712. expect(result.data.proxy_fallback_to_direct).toBe(true);
  713. expect(result.data.first_byte_timeout_streaming_ms).toBe(15000);
  714. expect(result.data.mcp_passthrough_type).toBe("minimax");
  715. expect(result.data.mcp_passthrough_url).toBe("https://api.minimaxi.com");
  716. });
  717. });
  718. });