session.test.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780
  1. import { describe, expect, it, vi } from "vitest";
  2. import type { ModelPrice, ModelPriceData } from "@/types/model-price";
  3. import type { SystemSettings } from "@/types/system-config";
  4. import type { Provider } from "@/types/provider";
  5. vi.mock("@/repository/model-price", () => ({
  6. findLatestPriceByModel: vi.fn(),
  7. }));
  8. vi.mock("@/repository/system-config", () => ({
  9. getSystemSettings: vi.fn(),
  10. }));
  11. import { ProxySession } from "@/app/v1/_lib/proxy/session";
  12. import { findLatestPriceByModel } from "@/repository/model-price";
  13. import { getSystemSettings } from "@/repository/system-config";
  14. function makeSystemSettings(
  15. billingModelSource: SystemSettings["billingModelSource"]
  16. ): SystemSettings {
  17. const now = new Date();
  18. return {
  19. id: 1,
  20. siteTitle: "test",
  21. allowGlobalUsageView: false,
  22. currencyDisplay: "USD",
  23. billingModelSource,
  24. enableAutoCleanup: false,
  25. cleanupRetentionDays: 30,
  26. cleanupSchedule: "0 2 * * *",
  27. cleanupBatchSize: 10000,
  28. enableClientVersionCheck: false,
  29. verboseProviderError: false,
  30. enableHttp2: false,
  31. interceptAnthropicWarmupRequests: false,
  32. enableResponseFixer: true,
  33. responseFixerConfig: {
  34. fixTruncatedJson: true,
  35. fixSseFormat: true,
  36. fixEncoding: true,
  37. maxJsonDepth: 200,
  38. maxFixSize: 1024 * 1024,
  39. },
  40. createdAt: now,
  41. updatedAt: now,
  42. };
  43. }
  44. function makePriceRecord(modelName: string, priceData: ModelPriceData): ModelPrice {
  45. return {
  46. id: 1,
  47. modelName,
  48. priceData,
  49. createdAt: new Date(),
  50. updatedAt: new Date(),
  51. };
  52. }
  53. function createSession({
  54. originalModel,
  55. redirectedModel,
  56. requestUrl,
  57. requestMessage,
  58. }: {
  59. originalModel?: string | null;
  60. redirectedModel?: string | null;
  61. requestUrl?: URL;
  62. requestMessage?: Record<string, unknown>;
  63. }): ProxySession {
  64. const session = new (
  65. ProxySession as unknown as {
  66. new (init: {
  67. startTime: number;
  68. method: string;
  69. requestUrl: URL;
  70. headers: Headers;
  71. headerLog: string;
  72. request: { message: Record<string, unknown>; log: string; model: string | null };
  73. userAgent: string | null;
  74. context: unknown;
  75. clientAbortSignal: AbortSignal | null;
  76. }): ProxySession;
  77. }
  78. )({
  79. startTime: Date.now(),
  80. method: "POST",
  81. requestUrl: requestUrl ?? new URL("http://localhost/v1/messages"),
  82. headers: new Headers(),
  83. headerLog: "",
  84. request: { message: requestMessage ?? {}, log: "(test)", model: redirectedModel ?? null },
  85. userAgent: null,
  86. context: {},
  87. clientAbortSignal: null,
  88. });
  89. if (originalModel !== undefined) {
  90. session.setOriginalModel(originalModel);
  91. }
  92. return session;
  93. }
  94. describe("ProxySession.getCachedPriceDataByBillingSource", () => {
  95. it("配置 = original 时应优先使用原始模型", async () => {
  96. const originalPriceData: ModelPriceData = { input_cost_per_token: 1, output_cost_per_token: 2 };
  97. const redirectedPriceData: ModelPriceData = {
  98. input_cost_per_token: 3,
  99. output_cost_per_token: 4,
  100. };
  101. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("original"));
  102. vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => {
  103. if (modelName === "original-model") {
  104. return makePriceRecord(modelName, originalPriceData);
  105. }
  106. if (modelName === "redirected-model") {
  107. return makePriceRecord(modelName, redirectedPriceData);
  108. }
  109. return null;
  110. });
  111. const session = createSession({
  112. originalModel: "original-model",
  113. redirectedModel: "redirected-model",
  114. });
  115. const result = await session.getCachedPriceDataByBillingSource();
  116. expect(result).toEqual(originalPriceData);
  117. expect(findLatestPriceByModel).toHaveBeenCalledTimes(1);
  118. expect(findLatestPriceByModel).toHaveBeenCalledWith("original-model");
  119. });
  120. it("配置 = redirected 时应优先使用重定向后模型", async () => {
  121. const originalPriceData: ModelPriceData = { input_cost_per_token: 1, output_cost_per_token: 2 };
  122. const redirectedPriceData: ModelPriceData = {
  123. input_cost_per_token: 3,
  124. output_cost_per_token: 4,
  125. };
  126. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected"));
  127. vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => {
  128. if (modelName === "original-model") {
  129. return makePriceRecord(modelName, originalPriceData);
  130. }
  131. if (modelName === "redirected-model") {
  132. return makePriceRecord(modelName, redirectedPriceData);
  133. }
  134. return null;
  135. });
  136. const session = createSession({
  137. originalModel: "original-model",
  138. redirectedModel: "redirected-model",
  139. });
  140. const result = await session.getCachedPriceDataByBillingSource();
  141. expect(result).toEqual(redirectedPriceData);
  142. expect(findLatestPriceByModel).toHaveBeenCalledTimes(1);
  143. expect(findLatestPriceByModel).toHaveBeenCalledWith("redirected-model");
  144. });
  145. it("应忽略空 priceData 并回退到备选模型", async () => {
  146. const redirectedPriceData: ModelPriceData = {
  147. input_cost_per_token: 3,
  148. output_cost_per_token: 4,
  149. };
  150. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("original"));
  151. vi.mocked(findLatestPriceByModel)
  152. .mockResolvedValueOnce(makePriceRecord("original-model", {}))
  153. .mockResolvedValueOnce(makePriceRecord("redirected-model", redirectedPriceData));
  154. const session = createSession({
  155. originalModel: "original-model",
  156. redirectedModel: "redirected-model",
  157. });
  158. const result = await session.getCachedPriceDataByBillingSource();
  159. expect(result).toEqual(redirectedPriceData);
  160. expect(findLatestPriceByModel).toHaveBeenCalledTimes(2);
  161. expect(findLatestPriceByModel).toHaveBeenNthCalledWith(1, "original-model");
  162. expect(findLatestPriceByModel).toHaveBeenNthCalledWith(2, "redirected-model");
  163. });
  164. it("应在主模型无价格时回退到备选模型", async () => {
  165. const redirectedPriceData: ModelPriceData = {
  166. input_cost_per_token: 3,
  167. output_cost_per_token: 4,
  168. };
  169. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("original"));
  170. vi.mocked(findLatestPriceByModel)
  171. .mockResolvedValueOnce(null)
  172. .mockResolvedValueOnce(makePriceRecord("redirected-model", redirectedPriceData));
  173. const session = createSession({
  174. originalModel: "original-model",
  175. redirectedModel: "redirected-model",
  176. });
  177. const result = await session.getCachedPriceDataByBillingSource();
  178. expect(result).toEqual(redirectedPriceData);
  179. expect(findLatestPriceByModel).toHaveBeenCalledTimes(2);
  180. expect(findLatestPriceByModel).toHaveBeenNthCalledWith(1, "original-model");
  181. expect(findLatestPriceByModel).toHaveBeenNthCalledWith(2, "redirected-model");
  182. });
  183. it("应在 getSystemSettings 失败时回退到 redirected", async () => {
  184. const redirectedPriceData: ModelPriceData = {
  185. input_cost_per_token: 3,
  186. output_cost_per_token: 4,
  187. };
  188. vi.mocked(getSystemSettings).mockRejectedValue(new Error("DB error"));
  189. vi.mocked(findLatestPriceByModel).mockResolvedValue(
  190. makePriceRecord("redirected-model", redirectedPriceData)
  191. );
  192. const session = createSession({
  193. originalModel: "original-model",
  194. redirectedModel: "redirected-model",
  195. });
  196. const result = await session.getCachedPriceDataByBillingSource();
  197. expect(result).toEqual(redirectedPriceData);
  198. expect(getSystemSettings).toHaveBeenCalledTimes(1);
  199. expect(findLatestPriceByModel).toHaveBeenCalledTimes(1);
  200. expect(findLatestPriceByModel).toHaveBeenCalledWith("redirected-model");
  201. const internal = session as unknown as { cachedBillingModelSource?: unknown };
  202. expect(internal.cachedBillingModelSource).toBe("redirected");
  203. });
  204. it("应在 billingModelSource 非法时回退到 redirected", async () => {
  205. const redirectedPriceData: ModelPriceData = {
  206. input_cost_per_token: 3,
  207. output_cost_per_token: 4,
  208. };
  209. vi.mocked(getSystemSettings).mockResolvedValue({
  210. ...makeSystemSettings("redirected"),
  211. billingModelSource: "invalid" as any,
  212. } as any);
  213. vi.mocked(findLatestPriceByModel).mockResolvedValue(
  214. makePriceRecord("redirected-model", redirectedPriceData)
  215. );
  216. const session = createSession({
  217. originalModel: "original-model",
  218. redirectedModel: "redirected-model",
  219. });
  220. const result = await session.getCachedPriceDataByBillingSource();
  221. expect(result).toEqual(redirectedPriceData);
  222. expect(findLatestPriceByModel).toHaveBeenCalledTimes(1);
  223. expect(findLatestPriceByModel).toHaveBeenCalledWith("redirected-model");
  224. const internal = session as unknown as { cachedBillingModelSource?: unknown };
  225. expect(internal.cachedBillingModelSource).toBe("redirected");
  226. });
  227. it("当原始模型等于重定向模型时应避免重复查询", async () => {
  228. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("original"));
  229. vi.mocked(findLatestPriceByModel).mockResolvedValue(null);
  230. const session = createSession({
  231. originalModel: "same-model",
  232. redirectedModel: "same-model",
  233. });
  234. const result = await session.getCachedPriceDataByBillingSource();
  235. expect(result).toBeNull();
  236. expect(findLatestPriceByModel).toHaveBeenCalledTimes(1);
  237. expect(findLatestPriceByModel).toHaveBeenCalledWith("same-model");
  238. });
  239. it("并发调用时应只读取一次配置", async () => {
  240. const priceData: ModelPriceData = { input_cost_per_token: 1, output_cost_per_token: 2 };
  241. vi.mocked(getSystemSettings).mockImplementation(async () => {
  242. await new Promise((resolve) => setTimeout(resolve, 10));
  243. return makeSystemSettings("redirected");
  244. });
  245. vi.mocked(findLatestPriceByModel).mockResolvedValue(
  246. makePriceRecord("redirected-model", priceData)
  247. );
  248. const session = createSession({
  249. originalModel: "original-model",
  250. redirectedModel: "redirected-model",
  251. });
  252. const p1 = session.getCachedPriceDataByBillingSource();
  253. const p2 = session.getCachedPriceDataByBillingSource();
  254. await Promise.all([p1, p2]);
  255. expect(getSystemSettings).toHaveBeenCalledTimes(1);
  256. });
  257. it("应缓存配置避免重复读取", async () => {
  258. const priceData: ModelPriceData = { input_cost_per_token: 1, output_cost_per_token: 2 };
  259. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected"));
  260. vi.mocked(findLatestPriceByModel).mockResolvedValue(
  261. makePriceRecord("redirected-model", priceData)
  262. );
  263. const session = createSession({
  264. originalModel: "original-model",
  265. redirectedModel: "redirected-model",
  266. });
  267. await session.getCachedPriceDataByBillingSource();
  268. await session.getCachedPriceDataByBillingSource();
  269. expect(getSystemSettings).toHaveBeenCalledTimes(1);
  270. });
  271. it("应缓存价格数据避免重复查询", async () => {
  272. const priceData: ModelPriceData = { input_cost_per_token: 1, output_cost_per_token: 2 };
  273. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected"));
  274. vi.mocked(findLatestPriceByModel).mockResolvedValue(
  275. makePriceRecord("redirected-model", priceData)
  276. );
  277. const session = createSession({
  278. originalModel: "original-model",
  279. redirectedModel: "redirected-model",
  280. });
  281. await session.getCachedPriceDataByBillingSource();
  282. await session.getCachedPriceDataByBillingSource();
  283. expect(findLatestPriceByModel).toHaveBeenCalledTimes(1);
  284. });
  285. it("应在无模型时返回 null", async () => {
  286. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected"));
  287. const session = createSession({ redirectedModel: null });
  288. const result = await session.getCachedPriceDataByBillingSource();
  289. expect(result).toBeNull();
  290. expect(getSystemSettings).not.toHaveBeenCalled();
  291. expect(findLatestPriceByModel).not.toHaveBeenCalled();
  292. });
  293. });
  294. function createSessionForHeaders(headers: Headers): ProxySession {
  295. // 使用 ProxySession 的内部构造方法创建测试实例
  296. const testSession = ProxySession.fromContext as any;
  297. const session = Object.create(ProxySession.prototype);
  298. Object.assign(session, {
  299. startTime: Date.now(),
  300. method: "POST",
  301. requestUrl: new URL("https://example.com/v1/messages"),
  302. headers,
  303. originalHeaders: new Headers(headers), // 同步更新 originalHeaders
  304. headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
  305. request: { message: {}, log: "" },
  306. userAgent: headers.get("user-agent"),
  307. context: null,
  308. clientAbortSignal: null,
  309. userName: "test-user",
  310. authState: null,
  311. provider: null,
  312. messageContext: null,
  313. sessionId: null,
  314. requestSequence: 1,
  315. originalFormat: "claude",
  316. providerType: null,
  317. originalModelName: null,
  318. originalUrlPathname: null,
  319. providerChain: [],
  320. cacheTtlResolved: null,
  321. context1mApplied: false,
  322. cachedPriceData: undefined,
  323. cachedBillingModelSource: undefined,
  324. });
  325. return session;
  326. }
  327. describe("ProxySession - isHeaderModified", () => {
  328. it("应该检测到被修改的 header", () => {
  329. const headers = new Headers([["user-agent", "original"]]);
  330. const session = createSessionForHeaders(headers);
  331. session.headers.set("user-agent", "modified");
  332. expect(session.isHeaderModified("user-agent")).toBe(true);
  333. });
  334. it("应该检测未修改的 header", () => {
  335. const headers = new Headers([["user-agent", "same"]]);
  336. const session = createSessionForHeaders(headers);
  337. expect(session.isHeaderModified("user-agent")).toBe(false);
  338. });
  339. it("应该处理不存在的 header", () => {
  340. const headers = new Headers();
  341. const session = createSessionForHeaders(headers);
  342. expect(session.isHeaderModified("x-custom")).toBe(false);
  343. });
  344. it("应该检测到被删除的 header", () => {
  345. const headers = new Headers([["user-agent", "original"]]);
  346. const session = createSessionForHeaders(headers);
  347. session.headers.delete("user-agent");
  348. expect(session.isHeaderModified("user-agent")).toBe(true);
  349. });
  350. it("应该检测到新增的 header", () => {
  351. const headers = new Headers();
  352. const session = createSessionForHeaders(headers);
  353. session.headers.set("x-new-header", "new-value");
  354. expect(session.isHeaderModified("x-new-header")).toBe(true);
  355. });
  356. it("应该区分空字符串和 null", () => {
  357. const headers = new Headers([["x-test", ""]]);
  358. const session = createSessionForHeaders(headers);
  359. session.headers.delete("x-test");
  360. expect(session.isHeaderModified("x-test")).toBe(true); // "" -> null
  361. expect(session.headers.get("x-test")).toBeNull();
  362. });
  363. });
  364. describe("ProxySession.isWarmupRequest", () => {
  365. it("应识别合法的 Warmup 请求(忽略大小写与首尾空格)", () => {
  366. const session = createSession({
  367. redirectedModel: "claude-sonnet-4-5-20250929",
  368. requestMessage: {
  369. model: "claude-sonnet-4-5-20250929",
  370. messages: [
  371. {
  372. role: "user",
  373. content: [
  374. {
  375. type: "text",
  376. text: " WaRmUp ",
  377. cache_control: { type: "ephemeral" },
  378. },
  379. ],
  380. },
  381. ],
  382. },
  383. });
  384. expect(session.isWarmupRequest()).toBe(true);
  385. });
  386. it("endpoint 非 /v1/messages 时不应命中", () => {
  387. const session = createSession({
  388. redirectedModel: "claude-sonnet-4-5-20250929",
  389. requestUrl: new URL("http://localhost/v1/messages/count_tokens"),
  390. requestMessage: {
  391. messages: [
  392. {
  393. role: "user",
  394. content: [
  395. {
  396. type: "text",
  397. text: "Warmup",
  398. cache_control: { type: "ephemeral" },
  399. },
  400. ],
  401. },
  402. ],
  403. },
  404. });
  405. expect(session.isWarmupRequest()).toBe(false);
  406. });
  407. it("缺少 cache_control 或 type 不为 ephemeral 时不应命中", () => {
  408. const missingCacheControl = createSession({
  409. redirectedModel: "claude-sonnet-4-5-20250929",
  410. requestMessage: {
  411. messages: [
  412. {
  413. role: "user",
  414. content: [{ type: "text", text: "Warmup" }],
  415. },
  416. ],
  417. },
  418. });
  419. expect(missingCacheControl.isWarmupRequest()).toBe(false);
  420. const wrongCacheControl = createSession({
  421. redirectedModel: "claude-sonnet-4-5-20250929",
  422. requestMessage: {
  423. messages: [
  424. {
  425. role: "user",
  426. content: [
  427. {
  428. type: "text",
  429. text: "Warmup",
  430. cache_control: { type: "persistent" },
  431. },
  432. ],
  433. },
  434. ],
  435. },
  436. });
  437. expect(wrongCacheControl.isWarmupRequest()).toBe(false);
  438. });
  439. it("messages/content 非严格形态时不应命中(防误判)", () => {
  440. const multiMessages = createSession({
  441. redirectedModel: "claude-sonnet-4-5-20250929",
  442. requestMessage: {
  443. messages: [
  444. {
  445. role: "user",
  446. content: [
  447. {
  448. type: "text",
  449. text: "Warmup",
  450. cache_control: { type: "ephemeral" },
  451. },
  452. ],
  453. },
  454. {
  455. role: "user",
  456. content: [
  457. {
  458. type: "text",
  459. text: "Warmup",
  460. cache_control: { type: "ephemeral" },
  461. },
  462. ],
  463. },
  464. ],
  465. },
  466. });
  467. expect(multiMessages.isWarmupRequest()).toBe(false);
  468. const multiBlocks = createSession({
  469. redirectedModel: "claude-sonnet-4-5-20250929",
  470. requestMessage: {
  471. messages: [
  472. {
  473. role: "user",
  474. content: [
  475. {
  476. type: "text",
  477. text: "Warmup",
  478. cache_control: { type: "ephemeral" },
  479. },
  480. { type: "text", text: "Warmup", cache_control: { type: "ephemeral" } },
  481. ],
  482. },
  483. ],
  484. },
  485. });
  486. expect(multiBlocks.isWarmupRequest()).toBe(false);
  487. });
  488. it("messages/role/content 结构异常时不应命中", () => {
  489. const missingMessages = createSession({
  490. redirectedModel: "claude-sonnet-4-5-20250929",
  491. requestMessage: {},
  492. });
  493. expect(missingMessages.isWarmupRequest()).toBe(false);
  494. const nonArrayMessages = createSession({
  495. redirectedModel: "claude-sonnet-4-5-20250929",
  496. requestMessage: { messages: "Warmup" },
  497. });
  498. expect(nonArrayMessages.isWarmupRequest()).toBe(false);
  499. const roleNotUser = createSession({
  500. redirectedModel: "claude-sonnet-4-5-20250929",
  501. requestMessage: {
  502. messages: [
  503. {
  504. role: "assistant",
  505. content: [
  506. {
  507. type: "text",
  508. text: "Warmup",
  509. cache_control: { type: "ephemeral" },
  510. },
  511. ],
  512. },
  513. ],
  514. },
  515. });
  516. expect(roleNotUser.isWarmupRequest()).toBe(false);
  517. const contentNotArray = createSession({
  518. redirectedModel: "claude-sonnet-4-5-20250929",
  519. requestMessage: {
  520. messages: [{ role: "user", content: "Warmup" }],
  521. },
  522. });
  523. expect(contentNotArray.isWarmupRequest()).toBe(false);
  524. });
  525. it("block/text/cache_control 结构异常时不应命中", () => {
  526. const blockNotObject = createSession({
  527. redirectedModel: "claude-sonnet-4-5-20250929",
  528. requestMessage: {
  529. messages: [{ role: "user", content: [null] }],
  530. },
  531. });
  532. expect(blockNotObject.isWarmupRequest()).toBe(false);
  533. const typeNotText = createSession({
  534. redirectedModel: "claude-sonnet-4-5-20250929",
  535. requestMessage: {
  536. messages: [
  537. {
  538. role: "user",
  539. content: [
  540. {
  541. type: "image",
  542. text: "Warmup",
  543. cache_control: { type: "ephemeral" },
  544. },
  545. ],
  546. },
  547. ],
  548. },
  549. });
  550. expect(typeNotText.isWarmupRequest()).toBe(false);
  551. const textNotString = createSession({
  552. redirectedModel: "claude-sonnet-4-5-20250929",
  553. requestMessage: {
  554. messages: [
  555. {
  556. role: "user",
  557. content: [
  558. {
  559. type: "text",
  560. text: 123,
  561. cache_control: { type: "ephemeral" },
  562. },
  563. ],
  564. },
  565. ],
  566. },
  567. });
  568. expect(textNotString.isWarmupRequest()).toBe(false);
  569. const cacheControlNotObject = createSession({
  570. redirectedModel: "claude-sonnet-4-5-20250929",
  571. requestMessage: {
  572. messages: [
  573. {
  574. role: "user",
  575. content: [
  576. {
  577. type: "text",
  578. text: "Warmup",
  579. cache_control: null,
  580. },
  581. ],
  582. },
  583. ],
  584. },
  585. });
  586. expect(cacheControlNotObject.isWarmupRequest()).toBe(false);
  587. });
  588. });
  589. describe("ProxySession.addProviderToChain - endpoint audit", () => {
  590. it("应写入 vendorId/providerType/endpointId/endpointUrl", () => {
  591. const session = createSession({ redirectedModel: null });
  592. const provider = {
  593. id: 1,
  594. name: "p1",
  595. providerVendorId: 123,
  596. providerType: "claude",
  597. priority: 0,
  598. weight: 1,
  599. costMultiplier: 1,
  600. groupTag: null,
  601. } as unknown as Provider;
  602. session.addProviderToChain(provider, {
  603. endpointId: 42,
  604. endpointUrl: "https://api.example.com",
  605. });
  606. const chain = session.getProviderChain();
  607. expect(chain).toHaveLength(1);
  608. expect(chain[0]).toEqual(
  609. expect.objectContaining({
  610. id: 1,
  611. name: "p1",
  612. vendorId: 123,
  613. providerType: "claude",
  614. endpointId: 42,
  615. endpointUrl: "https://api.example.com",
  616. })
  617. );
  618. });
  619. it("同一 provider 连续写入且不带 attemptNumber 时应去重", () => {
  620. const session = createSession({ redirectedModel: null });
  621. const provider = {
  622. id: 1,
  623. name: "p1",
  624. providerVendorId: 123,
  625. providerType: "claude",
  626. priority: 0,
  627. weight: 1,
  628. costMultiplier: 1,
  629. groupTag: null,
  630. } as unknown as Provider;
  631. session.addProviderToChain(provider, { endpointId: 1, endpointUrl: "https://a.example.com" });
  632. session.addProviderToChain(provider, { endpointId: 2, endpointUrl: "https://b.example.com" });
  633. const chain = session.getProviderChain();
  634. expect(chain).toHaveLength(1);
  635. expect(chain[0]).toEqual(
  636. expect.objectContaining({
  637. endpointId: 1,
  638. endpointUrl: "https://a.example.com",
  639. })
  640. );
  641. });
  642. it("同一 provider 连续写入且带 attemptNumber 时应保留多条", () => {
  643. const session = createSession({ redirectedModel: null });
  644. const provider = {
  645. id: 1,
  646. name: "p1",
  647. providerVendorId: 123,
  648. providerType: "claude",
  649. priority: 0,
  650. weight: 1,
  651. costMultiplier: 1,
  652. groupTag: null,
  653. } as unknown as Provider;
  654. session.addProviderToChain(provider, {
  655. attemptNumber: 1,
  656. endpointId: 1,
  657. endpointUrl: "https://a.example.com",
  658. });
  659. session.addProviderToChain(provider, {
  660. attemptNumber: 2,
  661. endpointId: 2,
  662. endpointUrl: "https://b.example.com",
  663. });
  664. const chain = session.getProviderChain();
  665. expect(chain).toHaveLength(2);
  666. expect(chain[0]).toEqual(
  667. expect.objectContaining({
  668. attemptNumber: 1,
  669. endpointId: 1,
  670. endpointUrl: "https://a.example.com",
  671. })
  672. );
  673. expect(chain[1]).toEqual(
  674. expect.objectContaining({
  675. attemptNumber: 2,
  676. endpointId: 2,
  677. endpointUrl: "https://b.example.com",
  678. })
  679. );
  680. });
  681. });