session.test.ts 26 KB

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