session.test.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830
  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("应在无模型时返回 null", async () => {
  328. vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected"));
  329. const session = createSession({ redirectedModel: null });
  330. const result = await session.getCachedPriceDataByBillingSource();
  331. expect(result).toBeNull();
  332. expect(getSystemSettings).not.toHaveBeenCalled();
  333. expect(findLatestPriceByModel).not.toHaveBeenCalled();
  334. });
  335. });
  336. function createSessionForHeaders(headers: Headers): ProxySession {
  337. // 使用 ProxySession 的内部构造方法创建测试实例
  338. const testSession = ProxySession.fromContext as any;
  339. const session = Object.create(ProxySession.prototype);
  340. Object.assign(session, {
  341. startTime: Date.now(),
  342. method: "POST",
  343. requestUrl: new URL("https://example.com/v1/messages"),
  344. headers,
  345. originalHeaders: new Headers(headers), // 同步更新 originalHeaders
  346. headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
  347. request: { message: {}, log: "" },
  348. userAgent: headers.get("user-agent"),
  349. context: null,
  350. clientAbortSignal: null,
  351. userName: "test-user",
  352. authState: null,
  353. provider: null,
  354. messageContext: null,
  355. sessionId: null,
  356. requestSequence: 1,
  357. originalFormat: "claude",
  358. providerType: null,
  359. originalModelName: null,
  360. originalUrlPathname: null,
  361. providerChain: [],
  362. cacheTtlResolved: null,
  363. context1mApplied: false,
  364. cachedPriceData: undefined,
  365. cachedBillingModelSource: undefined,
  366. });
  367. return session;
  368. }
  369. describe("ProxySession - isHeaderModified", () => {
  370. it("应该检测到被修改的 header", () => {
  371. const headers = new Headers([["user-agent", "original"]]);
  372. const session = createSessionForHeaders(headers);
  373. session.headers.set("user-agent", "modified");
  374. expect(session.isHeaderModified("user-agent")).toBe(true);
  375. });
  376. it("应该检测未修改的 header", () => {
  377. const headers = new Headers([["user-agent", "same"]]);
  378. const session = createSessionForHeaders(headers);
  379. expect(session.isHeaderModified("user-agent")).toBe(false);
  380. });
  381. it("应该处理不存在的 header", () => {
  382. const headers = new Headers();
  383. const session = createSessionForHeaders(headers);
  384. expect(session.isHeaderModified("x-custom")).toBe(false);
  385. });
  386. it("应该检测到被删除的 header", () => {
  387. const headers = new Headers([["user-agent", "original"]]);
  388. const session = createSessionForHeaders(headers);
  389. session.headers.delete("user-agent");
  390. expect(session.isHeaderModified("user-agent")).toBe(true);
  391. });
  392. it("应该检测到新增的 header", () => {
  393. const headers = new Headers();
  394. const session = createSessionForHeaders(headers);
  395. session.headers.set("x-new-header", "new-value");
  396. expect(session.isHeaderModified("x-new-header")).toBe(true);
  397. });
  398. it("应该区分空字符串和 null", () => {
  399. const headers = new Headers([["x-test", ""]]);
  400. const session = createSessionForHeaders(headers);
  401. session.headers.delete("x-test");
  402. expect(session.isHeaderModified("x-test")).toBe(true); // "" -> null
  403. expect(session.headers.get("x-test")).toBeNull();
  404. });
  405. });
  406. describe("ProxySession.isWarmupRequest", () => {
  407. it("应识别合法的 Warmup 请求(忽略大小写与首尾空格)", () => {
  408. const session = createSession({
  409. redirectedModel: "claude-sonnet-4-5-20250929",
  410. requestMessage: {
  411. model: "claude-sonnet-4-5-20250929",
  412. messages: [
  413. {
  414. role: "user",
  415. content: [
  416. {
  417. type: "text",
  418. text: " WaRmUp ",
  419. cache_control: { type: "ephemeral" },
  420. },
  421. ],
  422. },
  423. ],
  424. },
  425. });
  426. expect(session.isWarmupRequest()).toBe(true);
  427. });
  428. it("endpoint 非 /v1/messages 时不应命中", () => {
  429. const session = createSession({
  430. redirectedModel: "claude-sonnet-4-5-20250929",
  431. requestUrl: new URL("http://localhost/v1/messages/count_tokens"),
  432. requestMessage: {
  433. messages: [
  434. {
  435. role: "user",
  436. content: [
  437. {
  438. type: "text",
  439. text: "Warmup",
  440. cache_control: { type: "ephemeral" },
  441. },
  442. ],
  443. },
  444. ],
  445. },
  446. });
  447. expect(session.isWarmupRequest()).toBe(false);
  448. });
  449. it("缺少 cache_control 或 type 不为 ephemeral 时不应命中", () => {
  450. const missingCacheControl = createSession({
  451. redirectedModel: "claude-sonnet-4-5-20250929",
  452. requestMessage: {
  453. messages: [
  454. {
  455. role: "user",
  456. content: [{ type: "text", text: "Warmup" }],
  457. },
  458. ],
  459. },
  460. });
  461. expect(missingCacheControl.isWarmupRequest()).toBe(false);
  462. const wrongCacheControl = createSession({
  463. redirectedModel: "claude-sonnet-4-5-20250929",
  464. requestMessage: {
  465. messages: [
  466. {
  467. role: "user",
  468. content: [
  469. {
  470. type: "text",
  471. text: "Warmup",
  472. cache_control: { type: "persistent" },
  473. },
  474. ],
  475. },
  476. ],
  477. },
  478. });
  479. expect(wrongCacheControl.isWarmupRequest()).toBe(false);
  480. });
  481. it("messages/content 非严格形态时不应命中(防误判)", () => {
  482. const multiMessages = createSession({
  483. redirectedModel: "claude-sonnet-4-5-20250929",
  484. requestMessage: {
  485. messages: [
  486. {
  487. role: "user",
  488. content: [
  489. {
  490. type: "text",
  491. text: "Warmup",
  492. cache_control: { type: "ephemeral" },
  493. },
  494. ],
  495. },
  496. {
  497. role: "user",
  498. content: [
  499. {
  500. type: "text",
  501. text: "Warmup",
  502. cache_control: { type: "ephemeral" },
  503. },
  504. ],
  505. },
  506. ],
  507. },
  508. });
  509. expect(multiMessages.isWarmupRequest()).toBe(false);
  510. const multiBlocks = createSession({
  511. redirectedModel: "claude-sonnet-4-5-20250929",
  512. requestMessage: {
  513. messages: [
  514. {
  515. role: "user",
  516. content: [
  517. {
  518. type: "text",
  519. text: "Warmup",
  520. cache_control: { type: "ephemeral" },
  521. },
  522. { type: "text", text: "Warmup", cache_control: { type: "ephemeral" } },
  523. ],
  524. },
  525. ],
  526. },
  527. });
  528. expect(multiBlocks.isWarmupRequest()).toBe(false);
  529. });
  530. it("messages/role/content 结构异常时不应命中", () => {
  531. const missingMessages = createSession({
  532. redirectedModel: "claude-sonnet-4-5-20250929",
  533. requestMessage: {},
  534. });
  535. expect(missingMessages.isWarmupRequest()).toBe(false);
  536. const nonArrayMessages = createSession({
  537. redirectedModel: "claude-sonnet-4-5-20250929",
  538. requestMessage: { messages: "Warmup" },
  539. });
  540. expect(nonArrayMessages.isWarmupRequest()).toBe(false);
  541. const roleNotUser = createSession({
  542. redirectedModel: "claude-sonnet-4-5-20250929",
  543. requestMessage: {
  544. messages: [
  545. {
  546. role: "assistant",
  547. content: [
  548. {
  549. type: "text",
  550. text: "Warmup",
  551. cache_control: { type: "ephemeral" },
  552. },
  553. ],
  554. },
  555. ],
  556. },
  557. });
  558. expect(roleNotUser.isWarmupRequest()).toBe(false);
  559. const contentNotArray = createSession({
  560. redirectedModel: "claude-sonnet-4-5-20250929",
  561. requestMessage: {
  562. messages: [{ role: "user", content: "Warmup" }],
  563. },
  564. });
  565. expect(contentNotArray.isWarmupRequest()).toBe(false);
  566. });
  567. it("block/text/cache_control 结构异常时不应命中", () => {
  568. const blockNotObject = createSession({
  569. redirectedModel: "claude-sonnet-4-5-20250929",
  570. requestMessage: {
  571. messages: [{ role: "user", content: [null] }],
  572. },
  573. });
  574. expect(blockNotObject.isWarmupRequest()).toBe(false);
  575. const typeNotText = createSession({
  576. redirectedModel: "claude-sonnet-4-5-20250929",
  577. requestMessage: {
  578. messages: [
  579. {
  580. role: "user",
  581. content: [
  582. {
  583. type: "image",
  584. text: "Warmup",
  585. cache_control: { type: "ephemeral" },
  586. },
  587. ],
  588. },
  589. ],
  590. },
  591. });
  592. expect(typeNotText.isWarmupRequest()).toBe(false);
  593. const textNotString = createSession({
  594. redirectedModel: "claude-sonnet-4-5-20250929",
  595. requestMessage: {
  596. messages: [
  597. {
  598. role: "user",
  599. content: [
  600. {
  601. type: "text",
  602. text: 123,
  603. cache_control: { type: "ephemeral" },
  604. },
  605. ],
  606. },
  607. ],
  608. },
  609. });
  610. expect(textNotString.isWarmupRequest()).toBe(false);
  611. const cacheControlNotObject = createSession({
  612. redirectedModel: "claude-sonnet-4-5-20250929",
  613. requestMessage: {
  614. messages: [
  615. {
  616. role: "user",
  617. content: [
  618. {
  619. type: "text",
  620. text: "Warmup",
  621. cache_control: null,
  622. },
  623. ],
  624. },
  625. ],
  626. },
  627. });
  628. expect(cacheControlNotObject.isWarmupRequest()).toBe(false);
  629. });
  630. });
  631. describe("ProxySession.addProviderToChain - endpoint audit", () => {
  632. it("应写入 vendorId/providerType/endpointId/endpointUrl", () => {
  633. const session = createSession({ redirectedModel: null });
  634. const provider = {
  635. id: 1,
  636. name: "p1",
  637. providerVendorId: 123,
  638. providerType: "claude",
  639. priority: 0,
  640. weight: 1,
  641. costMultiplier: 1,
  642. groupTag: null,
  643. } as unknown as Provider;
  644. session.addProviderToChain(provider, {
  645. endpointId: 42,
  646. endpointUrl: "https://api.example.com",
  647. });
  648. const chain = session.getProviderChain();
  649. expect(chain).toHaveLength(1);
  650. expect(chain[0]).toEqual(
  651. expect.objectContaining({
  652. id: 1,
  653. name: "p1",
  654. vendorId: 123,
  655. providerType: "claude",
  656. endpointId: 42,
  657. endpointUrl: "https://api.example.com",
  658. })
  659. );
  660. });
  661. it("同一 provider 连续写入且不带 attemptNumber 时应去重", () => {
  662. const session = createSession({ redirectedModel: null });
  663. const provider = {
  664. id: 1,
  665. name: "p1",
  666. providerVendorId: 123,
  667. providerType: "claude",
  668. priority: 0,
  669. weight: 1,
  670. costMultiplier: 1,
  671. groupTag: null,
  672. } as unknown as Provider;
  673. session.addProviderToChain(provider, { endpointId: 1, endpointUrl: "https://a.example.com" });
  674. session.addProviderToChain(provider, { endpointId: 2, endpointUrl: "https://b.example.com" });
  675. const chain = session.getProviderChain();
  676. expect(chain).toHaveLength(1);
  677. expect(chain[0]).toEqual(
  678. expect.objectContaining({
  679. endpointId: 1,
  680. endpointUrl: "https://a.example.com",
  681. })
  682. );
  683. });
  684. it("同一 provider 连续写入且带 attemptNumber 时应保留多条", () => {
  685. const session = createSession({ redirectedModel: null });
  686. const provider = {
  687. id: 1,
  688. name: "p1",
  689. providerVendorId: 123,
  690. providerType: "claude",
  691. priority: 0,
  692. weight: 1,
  693. costMultiplier: 1,
  694. groupTag: null,
  695. } as unknown as Provider;
  696. session.addProviderToChain(provider, {
  697. attemptNumber: 1,
  698. endpointId: 1,
  699. endpointUrl: "https://a.example.com",
  700. });
  701. session.addProviderToChain(provider, {
  702. attemptNumber: 2,
  703. endpointId: 2,
  704. endpointUrl: "https://b.example.com",
  705. });
  706. const chain = session.getProviderChain();
  707. expect(chain).toHaveLength(2);
  708. expect(chain[0]).toEqual(
  709. expect.objectContaining({
  710. attemptNumber: 1,
  711. endpointId: 1,
  712. endpointUrl: "https://a.example.com",
  713. })
  714. );
  715. expect(chain[1]).toEqual(
  716. expect.objectContaining({
  717. attemptNumber: 2,
  718. endpointId: 2,
  719. endpointUrl: "https://b.example.com",
  720. })
  721. );
  722. });
  723. });