proxy-forwarder-raw-passthrough-regression.test.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import { beforeEach, describe, expect, it, vi } from "vitest";
  2. const mocks = vi.hoisted(() => ({
  3. isHttp2Enabled: vi.fn(async () => false),
  4. getCachedSystemSettings: vi.fn(async () => ({
  5. enableClaudeMetadataUserIdInjection: false,
  6. enableBillingHeaderRectifier: false,
  7. })),
  8. getProxyAgentForProvider: vi.fn(async () => null),
  9. getGlobalAgentPool: vi.fn(() => ({
  10. getAgent: vi.fn(),
  11. markOriginUnhealthy: vi.fn(),
  12. })),
  13. }));
  14. vi.mock("@/lib/config", async (importOriginal) => {
  15. const actual = await importOriginal<typeof import("@/lib/config")>();
  16. return {
  17. ...actual,
  18. isHttp2Enabled: mocks.isHttp2Enabled,
  19. getCachedSystemSettings: mocks.getCachedSystemSettings,
  20. };
  21. });
  22. vi.mock("@/lib/proxy-agent", () => ({
  23. getProxyAgentForProvider: mocks.getProxyAgentForProvider,
  24. getGlobalAgentPool: mocks.getGlobalAgentPool,
  25. }));
  26. import { resolveEndpointPolicy } from "@/app/v1/_lib/proxy/endpoint-policy";
  27. import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
  28. import { ProxySession } from "@/app/v1/_lib/proxy/session";
  29. import type { Provider } from "@/types/provider";
  30. function createProvider(): Provider {
  31. return {
  32. id: 1,
  33. name: "codex-upstream",
  34. providerType: "codex",
  35. url: "https://upstream.example.com/v1/responses",
  36. key: "upstream-key",
  37. preserveClientIp: false,
  38. priority: 0,
  39. maxRetryAttempts: 1,
  40. mcpPassthroughType: "none",
  41. mcpPassthroughUrl: null,
  42. } as unknown as Provider;
  43. }
  44. function createRawPassthroughSession(bodyText: string, extraHeaders?: HeadersInit): ProxySession {
  45. const headers = new Headers({
  46. "content-type": "application/json",
  47. "content-length": String(new TextEncoder().encode(bodyText).byteLength),
  48. ...Object.fromEntries(new Headers(extraHeaders).entries()),
  49. });
  50. const originalHeaders = new Headers(headers);
  51. const specialSettings: unknown[] = [];
  52. const session = Object.create(ProxySession.prototype);
  53. Object.assign(session, {
  54. startTime: Date.now(),
  55. method: "POST",
  56. requestUrl: new URL("https://proxy.example.com/v1/responses/compact?stream=false"),
  57. headers,
  58. originalHeaders,
  59. headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
  60. request: {
  61. model: "gpt-5",
  62. log: bodyText,
  63. message: JSON.parse(bodyText) as Record<string, unknown>,
  64. buffer: new TextEncoder().encode(bodyText).buffer,
  65. },
  66. userAgent: "CodexTest/1.0",
  67. context: null,
  68. clientAbortSignal: null,
  69. userName: "test-user",
  70. authState: { success: true, user: null, key: null, apiKey: null },
  71. provider: null,
  72. messageContext: null,
  73. sessionId: null,
  74. requestSequence: 1,
  75. originalFormat: "openai",
  76. providerType: null,
  77. originalModelName: null,
  78. originalUrlPathname: null,
  79. providerChain: [],
  80. cacheTtlResolved: null,
  81. context1mApplied: false,
  82. cachedPriceData: undefined,
  83. cachedBillingModelSource: undefined,
  84. forwardedRequestBody: null,
  85. endpointPolicy: resolveEndpointPolicy("/v1/responses/compact"),
  86. setCacheTtlResolved: vi.fn(),
  87. getCacheTtlResolved: vi.fn(() => null),
  88. getCurrentModel: vi.fn(() => "gpt-5"),
  89. clientRequestsContext1m: vi.fn(() => false),
  90. setContext1mApplied: vi.fn(),
  91. getContext1mApplied: vi.fn(() => false),
  92. getEndpointPolicy: vi.fn(() => resolveEndpointPolicy("/v1/responses/compact")),
  93. addSpecialSetting: vi.fn((setting: unknown) => {
  94. specialSettings.push(setting);
  95. }),
  96. getSpecialSettings: vi.fn(() => specialSettings),
  97. isHeaderModified: vi.fn((key: string) => originalHeaders.get(key) !== headers.get(key)),
  98. });
  99. return session as ProxySession;
  100. }
  101. function readBodyText(body: BodyInit | undefined): string | null {
  102. if (body == null) return null;
  103. if (typeof body === "string") return body;
  104. if (body instanceof ArrayBuffer) {
  105. return new TextDecoder().decode(body);
  106. }
  107. if (ArrayBuffer.isView(body)) {
  108. return new TextDecoder().decode(body);
  109. }
  110. throw new Error(`Unsupported body type: ${Object.prototype.toString.call(body)}`);
  111. }
  112. describe("ProxyForwarder raw passthrough regression", () => {
  113. beforeEach(() => {
  114. vi.clearAllMocks();
  115. });
  116. it("raw passthrough 应优先保留原始请求体字节,而不是重新 JSON.stringify", async () => {
  117. const originalBody = '{\n "model": "gpt-5",\n "input": [1, 2, 3]\n}\n';
  118. const session = createRawPassthroughSession(originalBody);
  119. const provider = createProvider();
  120. let capturedInit: { body?: BodyInit; headers?: HeadersInit } | null = null;
  121. const fetchWithoutAutoDecode = vi.spyOn(ProxyForwarder as any, "fetchWithoutAutoDecode");
  122. fetchWithoutAutoDecode.mockImplementationOnce(async (_url: string, init: RequestInit) => {
  123. capturedInit = { body: init.body ?? undefined, headers: init.headers ?? undefined };
  124. return new Response("{}", {
  125. status: 200,
  126. headers: { "content-type": "application/json", "content-length": "2" },
  127. });
  128. });
  129. const { doForward } = ProxyForwarder as unknown as {
  130. doForward: (session: ProxySession, provider: Provider, baseUrl: string) => Promise<Response>;
  131. };
  132. await doForward(session, provider, provider.url);
  133. expect(readBodyText(capturedInit?.body)).toBe(originalBody);
  134. });
  135. it("raw passthrough 出站请求不得继续携带 transfer-encoding 这类 hop-by-hop 头", async () => {
  136. const body = '{"model":"gpt-5","input":[]}';
  137. const session = createRawPassthroughSession(body, {
  138. connection: "keep-alive",
  139. "transfer-encoding": "chunked",
  140. });
  141. const provider = createProvider();
  142. let capturedHeaders: Headers | null = null;
  143. const fetchWithoutAutoDecode = vi.spyOn(ProxyForwarder as any, "fetchWithoutAutoDecode");
  144. fetchWithoutAutoDecode.mockImplementationOnce(async (_url: string, init: RequestInit) => {
  145. capturedHeaders = new Headers(init.headers);
  146. return new Response("{}", {
  147. status: 200,
  148. headers: { "content-type": "application/json", "content-length": "2" },
  149. });
  150. });
  151. const { doForward } = ProxyForwarder as unknown as {
  152. doForward: (session: ProxySession, provider: Provider, baseUrl: string) => Promise<Response>;
  153. };
  154. await doForward(session, provider, provider.url);
  155. expect(capturedHeaders?.get("connection")).toBeNull();
  156. expect(capturedHeaders?.get("transfer-encoding")).toBeNull();
  157. });
  158. });