session.test.ts 26 KB

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