proxy-forwarder.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. import { describe, expect, it } from "vitest";
  2. import type { Provider } from "@/types/provider";
  3. import { DEFAULT_CODEX_USER_AGENT, ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder";
  4. import { ProxySession } from "@/app/v1/_lib/proxy/session";
  5. function createSession({
  6. userAgent,
  7. headers,
  8. }: {
  9. userAgent: string | null;
  10. headers: Headers;
  11. }): ProxySession {
  12. // 使用 ProxySession 的内部构造方法创建测试实例
  13. const session = Object.create(ProxySession.prototype);
  14. Object.assign(session, {
  15. startTime: Date.now(),
  16. method: "POST",
  17. requestUrl: new URL("https://example.com/v1/messages"),
  18. headers,
  19. originalHeaders: new Headers(headers), // 同步更新 originalHeaders
  20. headerLog: JSON.stringify(Object.fromEntries(headers.entries())),
  21. request: { message: {}, log: "" },
  22. userAgent,
  23. context: null,
  24. clientAbortSignal: null,
  25. userName: "test-user",
  26. authState: null,
  27. provider: null,
  28. messageContext: null,
  29. sessionId: null,
  30. requestSequence: 1,
  31. originalFormat: "claude",
  32. providerType: null,
  33. originalModelName: null,
  34. originalUrlPathname: null,
  35. providerChain: [],
  36. cacheTtlResolved: null,
  37. context1mApplied: false,
  38. cachedPriceData: undefined,
  39. cachedBillingModelSource: undefined,
  40. isHeaderModified: (key: string) => {
  41. // 简化的 isHeaderModified 实现
  42. const original = session.originalHeaders?.get(key);
  43. const current = session.headers.get(key);
  44. return original !== current;
  45. },
  46. });
  47. return session as any;
  48. }
  49. function createCodexProvider(): Provider {
  50. return {
  51. providerType: "codex",
  52. url: "https://example.com/v1/responses",
  53. key: "test-outbound-key",
  54. preserveClientIp: false,
  55. } as unknown as Provider;
  56. }
  57. function createGeminiProvider(providerType: "gemini" | "gemini-cli"): Provider {
  58. return {
  59. providerType,
  60. url: "https://generativelanguage.googleapis.com/v1beta",
  61. key: "test-outbound-key",
  62. preserveClientIp: false,
  63. } as unknown as Provider;
  64. }
  65. describe("ProxyForwarder - buildHeaders User-Agent resolution", () => {
  66. it("应该优先使用过滤器修改的 user-agent(Codex provider)", () => {
  67. const session = createSession({
  68. userAgent: "Original-UA/1.0",
  69. headers: new Headers([["user-agent", "Filtered-UA/2.0"]]),
  70. });
  71. // 设置 originalHeaders 为不同值以模拟过滤器修改
  72. (session as any).originalHeaders = new Headers([["user-agent", "Original-UA/1.0"]]);
  73. const provider = createCodexProvider();
  74. const { buildHeaders } = ProxyForwarder as unknown as {
  75. buildHeaders: (session: ProxySession, provider: Provider) => Headers;
  76. };
  77. const resultHeaders = buildHeaders(session, provider);
  78. expect(resultHeaders.get("user-agent")).toBe("Filtered-UA/2.0");
  79. });
  80. it("应该使用原始 user-agent 当未被过滤器修改时", () => {
  81. const session = createSession({
  82. userAgent: "Original-UA/1.0",
  83. headers: new Headers([["user-agent", "Original-UA/1.0"]]),
  84. });
  85. // 原始和当前相同
  86. (session as any).originalHeaders = new Headers([["user-agent", "Original-UA/1.0"]]);
  87. const provider = createCodexProvider();
  88. const { buildHeaders } = ProxyForwarder as unknown as {
  89. buildHeaders: (session: ProxySession, provider: Provider) => Headers;
  90. };
  91. const resultHeaders = buildHeaders(session, provider);
  92. expect(resultHeaders.get("user-agent")).toBe("Original-UA/1.0");
  93. });
  94. it("应该使用原始 user-agent 当过滤器删除 header 时", () => {
  95. const session = createSession({
  96. userAgent: "Original-UA/1.0",
  97. headers: new Headers(), // user-agent 被删除
  98. });
  99. // originalHeaders 包含 user-agent,但当前 headers 没有
  100. (session as any).originalHeaders = new Headers([["user-agent", "Original-UA/1.0"]]);
  101. const provider = createCodexProvider();
  102. const { buildHeaders } = ProxyForwarder as unknown as {
  103. buildHeaders: (session: ProxySession, provider: Provider) => Headers;
  104. };
  105. const resultHeaders = buildHeaders(session, provider);
  106. expect(resultHeaders.get("user-agent")).toBe("Original-UA/1.0");
  107. });
  108. it("应该使用兜底 user-agent 当原始值为空且未修改时", () => {
  109. const session = createSession({
  110. userAgent: null,
  111. headers: new Headers(),
  112. });
  113. (session as any).originalHeaders = new Headers();
  114. const provider = createCodexProvider();
  115. const { buildHeaders } = ProxyForwarder as unknown as {
  116. buildHeaders: (session: ProxySession, provider: Provider) => Headers;
  117. };
  118. const resultHeaders = buildHeaders(session, provider);
  119. expect(resultHeaders.get("user-agent")).toBe(DEFAULT_CODEX_USER_AGENT);
  120. });
  121. it("应该保留过滤器设置的空字符串 user-agent", () => {
  122. const session = createSession({
  123. userAgent: "Original-UA/1.0",
  124. headers: new Headers([["user-agent", ""]]), // 空字符串
  125. });
  126. // originalHeaders 包含原始 UA,但当前是空字符串
  127. (session as any).originalHeaders = new Headers([["user-agent", "Original-UA/1.0"]]);
  128. const provider = createCodexProvider();
  129. const { buildHeaders } = ProxyForwarder as unknown as {
  130. buildHeaders: (session: ProxySession, provider: Provider) => Headers;
  131. };
  132. const resultHeaders = buildHeaders(session, provider);
  133. // 空字符串应该被保留(使用 ?? 而非 ||)
  134. expect(resultHeaders.get("user-agent")).toBe("");
  135. });
  136. it("应该剥离 transfer-encoding 这类传输层 header,避免向上游继续透传", () => {
  137. const session = createSession({
  138. userAgent: "Original-UA/1.0",
  139. headers: new Headers([
  140. ["user-agent", "Original-UA/1.0"],
  141. ["connection", "keep-alive"],
  142. ["transfer-encoding", "chunked"],
  143. ["content-length", "123"],
  144. ]),
  145. });
  146. const provider = createCodexProvider();
  147. const { buildHeaders } = ProxyForwarder as unknown as {
  148. buildHeaders: (session: ProxySession, provider: Provider) => Headers;
  149. };
  150. const resultHeaders = buildHeaders(session, provider);
  151. expect(resultHeaders.get("connection")).toBeNull();
  152. expect(resultHeaders.get("transfer-encoding")).toBeNull();
  153. expect(resultHeaders.get("content-length")).toBeNull();
  154. });
  155. });
  156. describe("ProxyForwarder - buildGeminiHeaders headers passthrough", () => {
  157. it("应该透传 user-agent,并覆盖上游 x-goog-api-key(API key 模式)", () => {
  158. const session = createSession({
  159. userAgent: "Original-UA/1.0",
  160. headers: new Headers([
  161. ["user-agent", "Original-UA/1.0"],
  162. ["x-api-key", "proxy-user-key-should-not-leak"],
  163. ["x-goog-api-key", "proxy-user-key-google-should-not-leak"],
  164. ["authorization", "Bearer proxy-user-bearer-should-not-leak"],
  165. ]),
  166. });
  167. const provider = createGeminiProvider("gemini");
  168. const { buildGeminiHeaders } = ProxyForwarder as unknown as {
  169. buildGeminiHeaders: (
  170. session: ProxySession,
  171. provider: Provider,
  172. baseUrl: string,
  173. accessToken: string,
  174. isApiKey: boolean
  175. ) => Headers;
  176. };
  177. const resultHeaders = buildGeminiHeaders(
  178. session,
  179. provider,
  180. "https://generativelanguage.googleapis.com/v1beta",
  181. "upstream-api-key",
  182. true
  183. );
  184. expect(resultHeaders.get("user-agent")).toBe("Original-UA/1.0");
  185. expect(resultHeaders.get("x-api-key")).toBeNull();
  186. expect(resultHeaders.get("authorization")).toBeNull();
  187. expect(resultHeaders.get("x-goog-api-key")).toBe("upstream-api-key");
  188. expect(resultHeaders.get("accept-encoding")).toBe("identity");
  189. expect(resultHeaders.get("content-type")).toBe("application/json");
  190. expect(resultHeaders.get("host")).toBe("generativelanguage.googleapis.com");
  191. });
  192. it("应该允许过滤器修改 user-agent(Gemini provider)", () => {
  193. const session = createSession({
  194. userAgent: "Original-UA/1.0",
  195. headers: new Headers([["user-agent", "Filtered-UA/2.0"]]),
  196. });
  197. (session as any).originalHeaders = new Headers([["user-agent", "Original-UA/1.0"]]);
  198. const provider = createGeminiProvider("gemini");
  199. const { buildGeminiHeaders } = ProxyForwarder as unknown as {
  200. buildGeminiHeaders: (
  201. session: ProxySession,
  202. provider: Provider,
  203. baseUrl: string,
  204. accessToken: string,
  205. isApiKey: boolean
  206. ) => Headers;
  207. };
  208. const resultHeaders = buildGeminiHeaders(
  209. session,
  210. provider,
  211. "https://generativelanguage.googleapis.com/v1beta",
  212. "upstream-api-key",
  213. true
  214. );
  215. expect(resultHeaders.get("user-agent")).toBe("Filtered-UA/2.0");
  216. });
  217. it("应该在过滤器删除 user-agent 时回退到原始 userAgent(Gemini provider)", () => {
  218. const session = createSession({
  219. userAgent: "Original-UA/1.0",
  220. headers: new Headers(), // user-agent 被删除
  221. });
  222. (session as any).originalHeaders = new Headers([["user-agent", "Original-UA/1.0"]]);
  223. const provider = createGeminiProvider("gemini");
  224. const { buildGeminiHeaders } = ProxyForwarder as unknown as {
  225. buildGeminiHeaders: (
  226. session: ProxySession,
  227. provider: Provider,
  228. baseUrl: string,
  229. accessToken: string,
  230. isApiKey: boolean
  231. ) => Headers;
  232. };
  233. const resultHeaders = buildGeminiHeaders(
  234. session,
  235. provider,
  236. "https://generativelanguage.googleapis.com/v1beta",
  237. "upstream-api-key",
  238. true
  239. );
  240. expect(resultHeaders.get("user-agent")).toBe("Original-UA/1.0");
  241. });
  242. it("应该使用 Authorization Bearer(OAuth 模式)并移除 x-goog-api-key", () => {
  243. const session = createSession({
  244. userAgent: "Original-UA/1.0",
  245. headers: new Headers([
  246. ["user-agent", "Original-UA/1.0"],
  247. ["x-goog-api-key", "proxy-user-key-google-should-not-leak"],
  248. ]),
  249. });
  250. const provider = createGeminiProvider("gemini");
  251. const { buildGeminiHeaders } = ProxyForwarder as unknown as {
  252. buildGeminiHeaders: (
  253. session: ProxySession,
  254. provider: Provider,
  255. baseUrl: string,
  256. accessToken: string,
  257. isApiKey: boolean
  258. ) => Headers;
  259. };
  260. const resultHeaders = buildGeminiHeaders(
  261. session,
  262. provider,
  263. "https://generativelanguage.googleapis.com/v1beta",
  264. "upstream-oauth-token",
  265. false
  266. );
  267. expect(resultHeaders.get("authorization")).toBe("Bearer upstream-oauth-token");
  268. expect(resultHeaders.get("x-goog-api-key")).toBeNull();
  269. });
  270. it("gemini-cli 应该注入 x-goog-api-client 头", () => {
  271. const session = createSession({
  272. userAgent: "Original-UA/1.0",
  273. headers: new Headers([["user-agent", "Original-UA/1.0"]]),
  274. });
  275. const provider = createGeminiProvider("gemini-cli");
  276. const { buildGeminiHeaders } = ProxyForwarder as unknown as {
  277. buildGeminiHeaders: (
  278. session: ProxySession,
  279. provider: Provider,
  280. baseUrl: string,
  281. accessToken: string,
  282. isApiKey: boolean
  283. ) => Headers;
  284. };
  285. const resultHeaders = buildGeminiHeaders(
  286. session,
  287. provider,
  288. "https://cloudcode-pa.googleapis.com/v1internal",
  289. "upstream-oauth-token",
  290. false
  291. );
  292. expect(resultHeaders.get("x-goog-api-client")).toBe("GeminiCLI/1.0");
  293. });
  294. it("Gemini 路径也应该剥离 transfer-encoding,避免请求体透传回归污染上游", () => {
  295. const session = createSession({
  296. userAgent: "Original-UA/1.0",
  297. headers: new Headers([
  298. ["user-agent", "Original-UA/1.0"],
  299. ["connection", "keep-alive"],
  300. ["transfer-encoding", "chunked"],
  301. ["content-length", "123"],
  302. ]),
  303. });
  304. const provider = createGeminiProvider("gemini");
  305. const { buildGeminiHeaders } = ProxyForwarder as unknown as {
  306. buildGeminiHeaders: (
  307. session: ProxySession,
  308. provider: Provider,
  309. baseUrl: string,
  310. accessToken: string,
  311. isApiKey: boolean
  312. ) => Headers;
  313. };
  314. const resultHeaders = buildGeminiHeaders(
  315. session,
  316. provider,
  317. "https://generativelanguage.googleapis.com/v1beta",
  318. "upstream-api-key",
  319. true
  320. );
  321. expect(resultHeaders.get("connection")).toBeNull();
  322. expect(resultHeaders.get("transfer-encoding")).toBeNull();
  323. expect(resultHeaders.get("content-length")).toBeNull();
  324. });
  325. });