session.test.ts 26 KB

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