session-guard-warmup-intercept.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. import type { ProxySession } from "@/app/v1/_lib/proxy/session";
  3. const getCachedSystemSettingsMock = vi.fn();
  4. const extractClientSessionIdMock = vi.fn();
  5. const getOrCreateSessionIdMock = vi.fn();
  6. const getNextRequestSequenceMock = vi.fn();
  7. const storeSessionRequestBodyMock = vi.fn(async () => undefined);
  8. const storeSessionClientRequestMetaMock = vi.fn(async () => undefined);
  9. const storeSessionMessagesMock = vi.fn(async () => undefined);
  10. const storeSessionInfoMock = vi.fn(async () => undefined);
  11. const generateSessionIdMock = vi.fn();
  12. const trackSessionMock = vi.fn(async () => undefined);
  13. vi.mock("@/lib/config", () => ({
  14. getCachedSystemSettings: () => getCachedSystemSettingsMock(),
  15. }));
  16. vi.mock("@/lib/session-manager", () => ({
  17. SessionManager: {
  18. extractClientSessionId: extractClientSessionIdMock,
  19. getOrCreateSessionId: getOrCreateSessionIdMock,
  20. getNextRequestSequence: getNextRequestSequenceMock,
  21. storeSessionRequestBody: storeSessionRequestBodyMock,
  22. storeSessionClientRequestMeta: storeSessionClientRequestMetaMock,
  23. storeSessionMessages: storeSessionMessagesMock,
  24. storeSessionInfo: storeSessionInfoMock,
  25. generateSessionId: generateSessionIdMock,
  26. },
  27. }));
  28. vi.mock("@/lib/session-tracker", () => ({
  29. SessionTracker: {
  30. trackSession: trackSessionMock,
  31. },
  32. }));
  33. vi.mock("@/lib/logger", () => ({
  34. logger: {
  35. debug: vi.fn(),
  36. info: vi.fn(),
  37. warn: vi.fn(),
  38. error: vi.fn(),
  39. fatal: vi.fn(),
  40. trace: vi.fn(),
  41. },
  42. }));
  43. async function loadGuard() {
  44. const mod = await import("@/app/v1/_lib/proxy/session-guard");
  45. return mod.ProxySessionGuard;
  46. }
  47. function createMockSession(overrides: Partial<ProxySession> = {}): ProxySession {
  48. const session: any = {
  49. authState: {
  50. success: true,
  51. user: { id: 1, name: "u" },
  52. key: { id: 1, name: "k" },
  53. apiKey: "api-key",
  54. },
  55. request: {
  56. message: {},
  57. model: "claude-sonnet-4-5-20250929",
  58. },
  59. headers: new Headers(),
  60. userAgent: "claude_cli/1.0",
  61. requestUrl: "http://localhost/v1/messages",
  62. method: "POST",
  63. originalFormat: "claude",
  64. highConcurrencyModeEnabled: false,
  65. addSpecialSetting: vi.fn(),
  66. setHighConcurrencyModeEnabled(enabled: boolean) {
  67. this.highConcurrencyModeEnabled = enabled;
  68. },
  69. shouldPersistSessionDebugArtifacts() {
  70. return !this.highConcurrencyModeEnabled;
  71. },
  72. shouldTrackSessionObservability() {
  73. return !this.highConcurrencyModeEnabled;
  74. },
  75. sessionId: null,
  76. setSessionId(id: string) {
  77. this.sessionId = id;
  78. },
  79. setRequestSequence(seq: number) {
  80. this.requestSequence = seq;
  81. },
  82. getRequestSequence() {
  83. return this.requestSequence ?? 1;
  84. },
  85. getMessages() {
  86. return [];
  87. },
  88. getMessagesLength() {
  89. return 1;
  90. },
  91. isWarmupRequest() {
  92. return true;
  93. },
  94. } satisfies Partial<ProxySession>;
  95. return { ...session, ...overrides } as ProxySession;
  96. }
  97. beforeEach(() => {
  98. vi.clearAllMocks();
  99. extractClientSessionIdMock.mockReturnValue(null);
  100. getOrCreateSessionIdMock.mockResolvedValue("session_assigned");
  101. getNextRequestSequenceMock.mockResolvedValue(1);
  102. getCachedSystemSettingsMock.mockResolvedValue({
  103. enableHighConcurrencyMode: false,
  104. interceptAnthropicWarmupRequests: true,
  105. enableClaudeMetadataUserIdInjection: true,
  106. });
  107. });
  108. describe("ProxySessionGuard:warmup 拦截不应计入并发会话", () => {
  109. test("当 warmup 且开关开启时,不应调用 SessionTracker.trackSession", async () => {
  110. const ProxySessionGuard = await loadGuard();
  111. const session = createMockSession({ isWarmupRequest: () => true });
  112. await ProxySessionGuard.ensure(session);
  113. expect(trackSessionMock).not.toHaveBeenCalled();
  114. expect(session.sessionId).toBe("session_assigned");
  115. });
  116. test("当 warmup 但开关关闭时,应正常调用 SessionTracker.trackSession", async () => {
  117. const ProxySessionGuard = await loadGuard();
  118. getCachedSystemSettingsMock.mockResolvedValueOnce({
  119. enableHighConcurrencyMode: false,
  120. interceptAnthropicWarmupRequests: false,
  121. });
  122. const session = createMockSession({ isWarmupRequest: () => true });
  123. await ProxySessionGuard.ensure(session);
  124. expect(trackSessionMock).toHaveBeenCalledTimes(1);
  125. expect(trackSessionMock).toHaveBeenCalledWith("session_assigned", 1, 1);
  126. });
  127. test("高并发模式:仍分配 session/requestSequence,但跳过 request 调试快照与 session 观测写入", async () => {
  128. const ProxySessionGuard = await loadGuard();
  129. getCachedSystemSettingsMock.mockResolvedValueOnce({
  130. enableHighConcurrencyMode: true,
  131. interceptAnthropicWarmupRequests: false,
  132. enableClaudeMetadataUserIdInjection: true,
  133. });
  134. const session = createMockSession({ isWarmupRequest: () => false });
  135. await ProxySessionGuard.ensure(session);
  136. expect(session.sessionId).toBe("session_assigned");
  137. expect(session.getRequestSequence()).toBe(1);
  138. expect(storeSessionRequestBodyMock).not.toHaveBeenCalled();
  139. expect(storeSessionClientRequestMetaMock).not.toHaveBeenCalled();
  140. expect(storeSessionMessagesMock).not.toHaveBeenCalled();
  141. expect(storeSessionInfoMock).not.toHaveBeenCalled();
  142. expect(trackSessionMock).not.toHaveBeenCalled();
  143. });
  144. test("Claude 旧版本请求缺少 user_id 但有 metadata.session_id 时,应使用最终 sessionId 补全 user_id", async () => {
  145. const ProxySessionGuard = await loadGuard();
  146. extractClientSessionIdMock.mockImplementation((requestMessage: Record<string, unknown>) => {
  147. const metadata =
  148. requestMessage.metadata && typeof requestMessage.metadata === "object"
  149. ? (requestMessage.metadata as Record<string, unknown>)
  150. : {};
  151. if (typeof metadata.session_id === "string") {
  152. return metadata.session_id;
  153. }
  154. if (typeof metadata.user_id === "string") {
  155. const marker = "_account__session_";
  156. const markerIndex = metadata.user_id.indexOf(marker);
  157. return markerIndex === -1 ? null : metadata.user_id.slice(markerIndex + marker.length);
  158. }
  159. return null;
  160. });
  161. const session = createMockSession({
  162. userAgent: "claude-cli/2.1.77 (external, cli)",
  163. request: {
  164. message: {
  165. metadata: {
  166. session_id: "sess_legacy_seed",
  167. },
  168. },
  169. model: "claude-sonnet-4-5-20250929",
  170. },
  171. isWarmupRequest: () => false,
  172. });
  173. await ProxySessionGuard.ensure(session);
  174. expect((session.request.message.metadata as Record<string, unknown>).user_id).toMatch(
  175. /^user_[a-f0-9]{64}_account__session_session_assigned$/
  176. );
  177. expect(getOrCreateSessionIdMock).toHaveBeenCalledWith(1, [], "sess_legacy_seed");
  178. });
  179. test("Claude 无客户端 session 时,不应预生成 session 写回请求体,而应回填已分配 session", async () => {
  180. const ProxySessionGuard = await loadGuard();
  181. extractClientSessionIdMock.mockImplementation((requestMessage: Record<string, unknown>) => {
  182. const metadata =
  183. requestMessage.metadata && typeof requestMessage.metadata === "object"
  184. ? (requestMessage.metadata as Record<string, unknown>)
  185. : {};
  186. if (typeof metadata.user_id === "string") {
  187. try {
  188. const parsed = JSON.parse(metadata.user_id) as { session_id?: string };
  189. return parsed.session_id ?? null;
  190. } catch {
  191. return null;
  192. }
  193. }
  194. return null;
  195. });
  196. const session = createMockSession({
  197. userAgent: null,
  198. request: {
  199. message: {},
  200. model: "claude-sonnet-4-5-20250929",
  201. },
  202. isWarmupRequest: () => false,
  203. });
  204. await ProxySessionGuard.ensure(session);
  205. expect(
  206. JSON.parse((session.request.message.metadata as Record<string, unknown>).user_id as string)
  207. ).toEqual({
  208. device_id: expect.stringMatching(/^[a-f0-9]{64}$/),
  209. account_uuid: "",
  210. session_id: "session_assigned",
  211. });
  212. expect(getOrCreateSessionIdMock).toHaveBeenCalledWith(1, [], null);
  213. expect(generateSessionIdMock).not.toHaveBeenCalled();
  214. });
  215. test("当 warmup 请求会被拦截时,不应补全 Claude metadata.user_id", async () => {
  216. const ProxySessionGuard = await loadGuard();
  217. const session = createMockSession({
  218. userAgent: "claude-cli/2.1.78 (external, cli)",
  219. request: {
  220. message: {},
  221. model: "claude-sonnet-4-5-20250929",
  222. },
  223. isWarmupRequest: () => true,
  224. });
  225. await ProxySessionGuard.ensure(session);
  226. expect((session.request.message as Record<string, unknown>).metadata).toBeUndefined();
  227. });
  228. });