cloud-price-table.test.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import { afterEach, describe, expect, it, vi } from "vitest";
  2. import {
  3. fetchCloudPriceTableToml,
  4. parseCloudPriceTableToml,
  5. } from "@/lib/price-sync/cloud-price-table";
  6. describe("parseCloudPriceTableToml", () => {
  7. it('parses [models."..."] tables into a model map', () => {
  8. const toml = [
  9. "[metadata]",
  10. 'version = "test"',
  11. "",
  12. '[models."m1"]',
  13. 'display_name = "Model One"',
  14. 'mode = "chat"',
  15. 'litellm_provider = "anthropic"',
  16. "input_cost_per_token = 0.000001",
  17. "supports_vision = true",
  18. "",
  19. '[models."m1".pricing."anthropic"]',
  20. "input_cost_per_token = 0.000001",
  21. "",
  22. '[models."m2"]',
  23. 'mode = "image_generation"',
  24. 'litellm_provider = "openai"',
  25. "output_cost_per_image = 0.02",
  26. "",
  27. ].join("\n");
  28. const result = parseCloudPriceTableToml(toml);
  29. expect(result.ok).toBe(true);
  30. if (!result.ok) return;
  31. expect(Object.keys(result.data.models).sort()).toEqual(["m1", "m2"]);
  32. expect(result.data.metadata?.version).toBe("test");
  33. expect(result.data.models.m1.display_name).toBe("Model One");
  34. expect(result.data.models.m1.mode).toBe("chat");
  35. expect(result.data.models.m1.litellm_provider).toBe("anthropic");
  36. expect(result.data.models.m1.supports_vision).toBe(true);
  37. const pricing = result.data.models.m1.pricing as {
  38. anthropic?: { input_cost_per_token?: number };
  39. };
  40. expect(pricing.anthropic?.input_cost_per_token).toBe(0.000001);
  41. });
  42. it("returns an error when models table is missing", () => {
  43. const toml = ["[metadata]", 'version = "test"'].join("\n");
  44. const result = parseCloudPriceTableToml(toml);
  45. expect(result.ok).toBe(false);
  46. });
  47. it("returns an error when TOML is invalid", () => {
  48. const toml = "[models\ninvalid = true";
  49. const result = parseCloudPriceTableToml(toml);
  50. expect(result.ok).toBe(false);
  51. });
  52. it("returns an error when models table is empty", () => {
  53. const toml = ["[models]"].join("\n");
  54. const result = parseCloudPriceTableToml(toml);
  55. expect(result.ok).toBe(false);
  56. });
  57. it("ignores reserved keys in models table", () => {
  58. const toml = [
  59. '[models."__proto__"]',
  60. 'mode = "chat"',
  61. "input_cost_per_token = 0.000001",
  62. "",
  63. '[models."safe-model"]',
  64. 'mode = "chat"',
  65. "input_cost_per_token = 0.000001",
  66. "",
  67. ].join("\n");
  68. const result = parseCloudPriceTableToml(toml);
  69. expect(result.ok).toBe(true);
  70. if (!result.ok) return;
  71. expect(Object.keys(result.data.models)).toEqual(["safe-model"]);
  72. });
  73. it("returns an error when root is not an object (defensive)", async () => {
  74. vi.resetModules();
  75. vi.doMock("@iarna/toml", () => ({
  76. default: {
  77. parse: () => 123,
  78. },
  79. }));
  80. const mod = await import("@/lib/price-sync/cloud-price-table");
  81. const result = mod.parseCloudPriceTableToml("[models]");
  82. expect(result.ok).toBe(false);
  83. vi.doUnmock("@iarna/toml");
  84. });
  85. });
  86. describe("fetchCloudPriceTableToml", () => {
  87. afterEach(() => {
  88. vi.useRealTimers();
  89. vi.unstubAllGlobals();
  90. });
  91. it("returns ok=true when response is ok and body is non-empty", async () => {
  92. vi.stubGlobal(
  93. "fetch",
  94. vi.fn(async () => ({
  95. ok: true,
  96. status: 200,
  97. text: async () => "toml content",
  98. }))
  99. );
  100. const result = await fetchCloudPriceTableToml("https://example.test/prices.toml");
  101. expect(result.ok).toBe(true);
  102. });
  103. it("returns ok=false when response is not ok", async () => {
  104. vi.stubGlobal(
  105. "fetch",
  106. vi.fn(async () => ({
  107. ok: false,
  108. status: 404,
  109. text: async () => "not found",
  110. }))
  111. );
  112. const result = await fetchCloudPriceTableToml("https://example.test/prices.toml");
  113. expect(result.ok).toBe(false);
  114. });
  115. it("returns ok=false when response url redirects to unexpected host", async () => {
  116. vi.stubGlobal(
  117. "fetch",
  118. vi.fn(async () => ({
  119. ok: true,
  120. status: 200,
  121. url: "https://evil.test/prices.toml",
  122. text: async () => "toml content",
  123. }))
  124. );
  125. const result = await fetchCloudPriceTableToml("https://example.test/prices.toml");
  126. expect(result.ok).toBe(false);
  127. });
  128. it("returns ok=false when response url redirects to unexpected pathname", async () => {
  129. vi.stubGlobal(
  130. "fetch",
  131. vi.fn(async () => ({
  132. ok: true,
  133. status: 200,
  134. url: "https://example.test/evil.toml",
  135. text: async () => "toml content",
  136. }))
  137. );
  138. const result = await fetchCloudPriceTableToml("https://example.test/prices.toml");
  139. expect(result.ok).toBe(false);
  140. });
  141. it("returns ok=false when url is invalid and fetch throws", async () => {
  142. vi.stubGlobal(
  143. "fetch",
  144. vi.fn(async () => {
  145. throw new Error("Invalid URL");
  146. })
  147. );
  148. const result = await fetchCloudPriceTableToml("not-a-url");
  149. expect(result.ok).toBe(false);
  150. });
  151. it("returns ok=false when response body is empty", async () => {
  152. vi.stubGlobal(
  153. "fetch",
  154. vi.fn(async () => ({
  155. ok: true,
  156. status: 200,
  157. text: async () => " ",
  158. }))
  159. );
  160. const result = await fetchCloudPriceTableToml("https://example.test/prices.toml");
  161. expect(result.ok).toBe(false);
  162. });
  163. it("returns ok=false when request times out and aborts", async () => {
  164. vi.useFakeTimers();
  165. vi.stubGlobal(
  166. "fetch",
  167. vi.fn(
  168. async (_url: string, init?: { signal?: AbortSignal }) =>
  169. await new Promise((_resolve, reject) => {
  170. init?.signal?.addEventListener("abort", () => {
  171. reject(new Error("AbortError"));
  172. });
  173. })
  174. )
  175. );
  176. const promise = fetchCloudPriceTableToml("https://example.test/prices.toml");
  177. await vi.advanceTimersByTimeAsync(10000);
  178. const result = await promise;
  179. expect(result.ok).toBe(false);
  180. });
  181. it("returns ok=false when fetch throws a non-Error value", async () => {
  182. vi.stubGlobal(
  183. "fetch",
  184. vi.fn(async () => {
  185. throw "boom";
  186. })
  187. );
  188. const result = await fetchCloudPriceTableToml("https://example.test/prices.toml");
  189. expect(result.ok).toBe(false);
  190. });
  191. });