proxy-forwarder-thinking-signature-rectifier.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. import { beforeEach, describe, expect, test, vi } from "vitest";
  2. const mocks = vi.hoisted(() => {
  3. return {
  4. getCachedSystemSettings: vi.fn(async () => ({
  5. enableThinkingSignatureRectifier: true,
  6. enableHighConcurrencyMode: false,
  7. })),
  8. recordSuccess: vi.fn(),
  9. recordFailure: vi.fn(async () => {}),
  10. getCircuitState: vi.fn(() => "closed"),
  11. getProviderHealthInfo: vi.fn(async () => ({
  12. health: { failureCount: 0 },
  13. config: { failureThreshold: 3 },
  14. })),
  15. updateMessageRequestDetails: vi.fn(async () => {}),
  16. storeSessionSpecialSettings: vi.fn(async () => {}),
  17. updateSessionBindingSmart: vi.fn(async () => ({
  18. updated: true,
  19. reason: "first_success",
  20. details: "mocked",
  21. })),
  22. updateSessionProvider: vi.fn(async () => {}),
  23. };
  24. });
  25. vi.mock("@/lib/config", async (importOriginal) => {
  26. const actual = await importOriginal<typeof import("@/lib/config")>();
  27. return {
  28. ...actual,
  29. isHttp2Enabled: vi.fn(async () => false),
  30. getCachedSystemSettings: mocks.getCachedSystemSettings,
  31. };
  32. });
  33. vi.mock("@/lib/circuit-breaker", () => ({
  34. getCircuitState: mocks.getCircuitState,
  35. getProviderHealthInfo: mocks.getProviderHealthInfo,
  36. recordFailure: mocks.recordFailure,
  37. recordSuccess: mocks.recordSuccess,
  38. }));
  39. vi.mock("@/repository/message", () => ({
  40. updateMessageRequestDetails: mocks.updateMessageRequestDetails,
  41. }));
  42. vi.mock("@/lib/session-manager", () => ({
  43. SessionManager: {
  44. storeSessionSpecialSettings: mocks.storeSessionSpecialSettings,
  45. updateSessionBindingSmart: mocks.updateSessionBindingSmart,
  46. updateSessionProvider: mocks.updateSessionProvider,
  47. },
  48. }));
  49. import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
  50. import { ProxyError } from "@/app/v1/_lib/proxy/errors";
  51. import { ProxySession } from "@/app/v1/_lib/proxy/session";
  52. import type { Provider } from "@/types/provider";
  53. function createSession(): ProxySession {
  54. const headers = new Headers();
  55. const session = Object.create(ProxySession.prototype);
  56. Object.assign(session, {
  57. startTime: Date.now(),
  58. method: "POST",
  59. requestUrl: new URL("https://example.com/v1/messages"),
  60. headers,
  61. originalHeaders: new Headers(headers),
  62. headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
  63. request: {
  64. model: "claude-test",
  65. log: "",
  66. message: {
  67. model: "claude-test",
  68. messages: [
  69. {
  70. role: "assistant",
  71. content: [
  72. { type: "thinking", thinking: "t", signature: "sig_thinking" },
  73. { type: "text", text: "hello", signature: "sig_text_should_remove" },
  74. { type: "redacted_thinking", data: "r", signature: "sig_redacted" },
  75. ],
  76. },
  77. ],
  78. },
  79. },
  80. userAgent: null,
  81. context: null,
  82. clientAbortSignal: null,
  83. userName: "test-user",
  84. authState: { success: true, user: null, key: null, apiKey: null },
  85. provider: null,
  86. messageContext: { id: 123, createdAt: new Date(), user: { id: 1 }, key: {}, apiKey: "k" },
  87. sessionId: null,
  88. requestSequence: 1,
  89. originalFormat: "claude",
  90. providerType: null,
  91. originalModelName: null,
  92. originalUrlPathname: null,
  93. providerChain: [],
  94. cacheTtlResolved: null,
  95. context1mApplied: false,
  96. specialSettings: [],
  97. cachedPriceData: undefined,
  98. cachedBillingModelSource: undefined,
  99. highConcurrencyModeEnabled: false,
  100. isHeaderModified: () => false,
  101. setHighConcurrencyModeEnabled(enabled: boolean) {
  102. this.highConcurrencyModeEnabled = enabled;
  103. },
  104. shouldPersistSessionDebugArtifacts() {
  105. return !this.highConcurrencyModeEnabled;
  106. },
  107. shouldTrackSessionObservability() {
  108. return !this.highConcurrencyModeEnabled;
  109. },
  110. });
  111. return session as any;
  112. }
  113. function createAnthropicProvider(): Provider {
  114. return {
  115. id: 1,
  116. name: "anthropic-1",
  117. providerType: "claude",
  118. url: "https://example.com/v1/messages",
  119. key: "k",
  120. preserveClientIp: false,
  121. priority: 0,
  122. } as unknown as Provider;
  123. }
  124. describe("ProxyForwarder - thinking signature rectifier", () => {
  125. beforeEach(() => {
  126. vi.clearAllMocks();
  127. mocks.getCachedSystemSettings.mockResolvedValue({
  128. enableThinkingSignatureRectifier: true,
  129. enableHighConcurrencyMode: false,
  130. });
  131. });
  132. test("首次命中特定 400 错误时应整流并对同供应商重试一次(成功后不抛错)", async () => {
  133. const session = createSession();
  134. session.setProvider(createAnthropicProvider());
  135. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  136. doForward.mockImplementationOnce(async () => {
  137. throw new ProxyError("Invalid `signature` in `thinking` block", 400, {
  138. body: "",
  139. providerId: 1,
  140. providerName: "anthropic-1",
  141. });
  142. });
  143. doForward.mockImplementationOnce(async (s: ProxySession) => {
  144. const msg = s.request.message as any;
  145. const blocks = msg.messages[0].content as any[];
  146. expect(blocks.some((b) => b.type === "thinking")).toBe(false);
  147. expect(blocks.some((b) => b.type === "redacted_thinking")).toBe(false);
  148. expect(blocks.some((b) => "signature" in b)).toBe(false);
  149. const body = JSON.stringify({
  150. type: "message",
  151. content: [{ type: "text", text: "ok" }],
  152. });
  153. return new Response(body, {
  154. status: 200,
  155. headers: {
  156. "content-type": "application/json",
  157. "content-length": String(body.length),
  158. },
  159. });
  160. });
  161. const response = await ProxyForwarder.send(session);
  162. expect(response.status).toBe(200);
  163. expect(doForward).toHaveBeenCalledTimes(2);
  164. expect(session.getProviderChain()?.length).toBeGreaterThanOrEqual(2);
  165. const special = session.getSpecialSettings();
  166. expect(special).not.toBeNull();
  167. expect(JSON.stringify(special)).toContain("thinking_signature_rectifier");
  168. expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
  169. });
  170. test("命中 invalid request 相关 400 错误时也应整流并对同供应商重试一次", async () => {
  171. const session = createSession();
  172. session.setProvider(createAnthropicProvider());
  173. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  174. doForward.mockImplementationOnce(async () => {
  175. throw new ProxyError("invalid request: malformed content", 400, {
  176. body: "",
  177. providerId: 1,
  178. providerName: "anthropic-1",
  179. });
  180. });
  181. doForward.mockImplementationOnce(async (s: ProxySession) => {
  182. const msg = s.request.message as any;
  183. const blocks = msg.messages[0].content as any[];
  184. expect(blocks.some((b) => b.type === "thinking")).toBe(false);
  185. expect(blocks.some((b) => b.type === "redacted_thinking")).toBe(false);
  186. expect(blocks.some((b) => "signature" in b)).toBe(false);
  187. const body = JSON.stringify({
  188. type: "message",
  189. content: [{ type: "text", text: "ok" }],
  190. });
  191. return new Response(body, {
  192. status: 200,
  193. headers: {
  194. "content-type": "application/json",
  195. "content-length": String(body.length),
  196. },
  197. });
  198. });
  199. const response = await ProxyForwarder.send(session);
  200. expect(response.status).toBe(200);
  201. expect(doForward).toHaveBeenCalledTimes(2);
  202. expect(session.getProviderChain()?.length).toBeGreaterThanOrEqual(2);
  203. const special = session.getSpecialSettings();
  204. expect(special).not.toBeNull();
  205. expect(JSON.stringify(special)).toContain("thinking_signature_rectifier");
  206. expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
  207. });
  208. test("thinking 启用但 assistant 首块为 tool_use 的 400 错误时,应关闭 thinking 并对同供应商重试一次", async () => {
  209. const session = createSession();
  210. session.setProvider(createAnthropicProvider());
  211. const msg = session.request.message as any;
  212. msg.thinking = { type: "enabled", budget_tokens: 1024 };
  213. msg.messages = [
  214. { role: "user", content: [{ type: "text", text: "hi" }] },
  215. {
  216. role: "assistant",
  217. content: [{ type: "tool_use", id: "toolu_1", name: "WebSearch", input: { query: "q" } }],
  218. },
  219. { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }] },
  220. ];
  221. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  222. doForward.mockImplementationOnce(async () => {
  223. throw new ProxyError(
  224. "messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`. When `thinking` is enabled, a final `assistant` message must start with a thinking block (preceeding the lastmost set of `tool_use` and `tool_result` blocks). To avoid this requirement, disable `thinking`.",
  225. 400,
  226. {
  227. body: "",
  228. providerId: 1,
  229. providerName: "anthropic-1",
  230. }
  231. );
  232. });
  233. doForward.mockImplementationOnce(async (s: ProxySession) => {
  234. const bodyMsg = s.request.message as any;
  235. expect(bodyMsg.thinking).toBeUndefined();
  236. const body = JSON.stringify({
  237. type: "message",
  238. content: [{ type: "text", text: "ok" }],
  239. });
  240. return new Response(body, {
  241. status: 200,
  242. headers: {
  243. "content-type": "application/json",
  244. "content-length": String(body.length),
  245. },
  246. });
  247. });
  248. const response = await ProxyForwarder.send(session);
  249. expect(response.status).toBe(200);
  250. expect(doForward).toHaveBeenCalledTimes(2);
  251. expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
  252. });
  253. test("移除 thinking block 后若 tool_use 置顶且 thinking 仍启用,应同时关闭 thinking 再重试", async () => {
  254. const session = createSession();
  255. session.setProvider(createAnthropicProvider());
  256. const msg = session.request.message as any;
  257. msg.thinking = { type: "enabled", budget_tokens: 1024 };
  258. msg.messages = [
  259. {
  260. role: "assistant",
  261. content: [
  262. { type: "thinking", thinking: "t", signature: "sig_thinking" },
  263. { type: "tool_use", id: "toolu_1", name: "WebSearch", input: { query: "q" } },
  264. ],
  265. },
  266. { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }] },
  267. ];
  268. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  269. doForward.mockImplementationOnce(async () => {
  270. throw new ProxyError("Invalid `signature` in `thinking` block", 400, {
  271. body: "",
  272. providerId: 1,
  273. providerName: "anthropic-1",
  274. });
  275. });
  276. doForward.mockImplementationOnce(async (s: ProxySession) => {
  277. const bodyMsg = s.request.message as any;
  278. const blocks = bodyMsg.messages[0].content as any[];
  279. expect(blocks.some((b) => b.type === "thinking")).toBe(false);
  280. expect(blocks.some((b) => b.type === "redacted_thinking")).toBe(false);
  281. expect(bodyMsg.thinking).toBeUndefined();
  282. const body = JSON.stringify({
  283. type: "message",
  284. content: [{ type: "text", text: "ok" }],
  285. });
  286. return new Response(body, {
  287. status: 200,
  288. headers: {
  289. "content-type": "application/json",
  290. "content-length": String(body.length),
  291. },
  292. });
  293. });
  294. const response = await ProxyForwarder.send(session);
  295. expect(response.status).toBe(200);
  296. expect(doForward).toHaveBeenCalledTimes(2);
  297. expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
  298. });
  299. test("匹配触发但无可整流内容时不应做无意义重试", async () => {
  300. const session = createSession();
  301. session.setProvider(createAnthropicProvider());
  302. const msg = session.request.message as any;
  303. msg.messages[0].content = [{ type: "text", text: "hello" }];
  304. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  305. doForward.mockImplementationOnce(async () => {
  306. throw new ProxyError("Invalid `signature` in `thinking` block", 400, {
  307. body: "",
  308. providerId: 1,
  309. providerName: "anthropic-1",
  310. });
  311. });
  312. await expect(ProxyForwarder.send(session)).rejects.toBeInstanceOf(ProxyError);
  313. expect(doForward).toHaveBeenCalledTimes(1);
  314. // 仍应写入一次审计字段,但不应触发第二次 doForward 调用
  315. expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
  316. const special = (session.getSpecialSettings() ?? []) as any[];
  317. const rectifier = special.find((s) => s.type === "thinking_signature_rectifier");
  318. expect(rectifier).toBeTruthy();
  319. expect(rectifier.hit).toBe(false);
  320. });
  321. test("重试后仍失败时应停止继续重试/切换,并按最终错误抛出", async () => {
  322. const session = createSession();
  323. session.setProvider(createAnthropicProvider());
  324. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  325. doForward.mockImplementationOnce(async () => {
  326. throw new ProxyError("Invalid `signature` in `thinking` block", 400, {
  327. body: "",
  328. providerId: 1,
  329. providerName: "anthropic-1",
  330. });
  331. });
  332. doForward.mockImplementationOnce(async () => {
  333. throw new ProxyError("Invalid `signature` in `thinking` block", 400, {
  334. body: "",
  335. providerId: 1,
  336. providerName: "anthropic-1",
  337. });
  338. });
  339. await expect(ProxyForwarder.send(session)).rejects.toBeInstanceOf(ProxyError);
  340. expect(doForward).toHaveBeenCalledTimes(2);
  341. // 第一次失败会写入审计字段,且只需要写一次(同一条 message_request 记录)
  342. expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
  343. const special = session.getSpecialSettings();
  344. expect(special).not.toBeNull();
  345. expect(JSON.stringify(special)).toContain("thinking_signature_rectifier");
  346. });
  347. test("命中 signature Extra inputs not permitted 错误时应整流并对同供应商重试一次", async () => {
  348. const session = createSession();
  349. session.setProvider(createAnthropicProvider());
  350. // 模拟包含 signature 字段的 tool_use content block
  351. const msg = session.request.message as any;
  352. msg.messages = [
  353. {
  354. role: "assistant",
  355. content: [
  356. { type: "text", text: "hello" },
  357. {
  358. type: "tool_use",
  359. id: "toolu_1",
  360. name: "WebSearch",
  361. input: { query: "q" },
  362. signature: "sig_tool_should_remove",
  363. },
  364. ],
  365. },
  366. ];
  367. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  368. doForward.mockImplementationOnce(async () => {
  369. throw new ProxyError("content.1.tool_use.signature: Extra inputs are not permitted", 400, {
  370. body: "",
  371. providerId: 1,
  372. providerName: "anthropic-1",
  373. });
  374. });
  375. doForward.mockImplementationOnce(async (s: ProxySession) => {
  376. const bodyMsg = s.request.message as any;
  377. const blocks = bodyMsg.messages[0].content as any[];
  378. // 验证 signature 字段已被移除
  379. expect(blocks.some((b: any) => "signature" in b)).toBe(false);
  380. const body = JSON.stringify({
  381. type: "message",
  382. content: [{ type: "text", text: "ok" }],
  383. });
  384. return new Response(body, {
  385. status: 200,
  386. headers: {
  387. "content-type": "application/json",
  388. "content-length": String(body.length),
  389. },
  390. });
  391. });
  392. const response = await ProxyForwarder.send(session);
  393. expect(response.status).toBe(200);
  394. expect(doForward).toHaveBeenCalledTimes(2);
  395. expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
  396. const special = session.getSpecialSettings();
  397. expect(special).not.toBeNull();
  398. expect(JSON.stringify(special)).toContain("thinking_signature_rectifier");
  399. });
  400. test("高并发模式:request-side rectifier 应继续写 DB specialSettings,但跳过 session Redis specialSettings", async () => {
  401. const session = createSession();
  402. session.sessionId = "sess_rectifier";
  403. session.setHighConcurrencyModeEnabled(true);
  404. session.setProvider(createAnthropicProvider());
  405. const doForward = vi.spyOn(ProxyForwarder as any, "doForward");
  406. doForward.mockImplementationOnce(async () => {
  407. throw new ProxyError("Invalid `signature` in `thinking` block", 400, {
  408. body: "",
  409. providerId: 1,
  410. providerName: "anthropic-1",
  411. });
  412. });
  413. doForward.mockImplementationOnce(async () => {
  414. const body = JSON.stringify({
  415. type: "message",
  416. content: [{ type: "text", text: "ok" }],
  417. });
  418. return new Response(body, {
  419. status: 200,
  420. headers: {
  421. "content-type": "application/json",
  422. "content-length": String(body.length),
  423. },
  424. });
  425. });
  426. const response = await ProxyForwarder.send(session);
  427. expect(response.status).toBe(200);
  428. expect(doForward).toHaveBeenCalledTimes(2);
  429. expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1);
  430. expect(mocks.storeSessionSpecialSettings).not.toHaveBeenCalled();
  431. });
  432. });