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

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