proxy-forwarder.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  1. import { describe, expect, it } from "vitest";
  2. import type { Provider } from "@/types/provider";
  3. import { 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(
  120. "codex_cli_rs/0.55.0 (Mac OS 26.1.0; arm64) vscode/2.0.64"
  121. );
  122. });
  123. it("应该保留过滤器设置的空字符串 user-agent", () => {
  124. const session = createSession({
  125. userAgent: "Original-UA/1.0",
  126. headers: new Headers([["user-agent", ""]]), // 空字符串
  127. });
  128. // originalHeaders 包含原始 UA,但当前是空字符串
  129. (session as any).originalHeaders = new Headers([["user-agent", "Original-UA/1.0"]]);
  130. const provider = createCodexProvider();
  131. const { buildHeaders } = ProxyForwarder as unknown as {
  132. buildHeaders: (session: ProxySession, provider: Provider) => Headers;
  133. };
  134. const resultHeaders = buildHeaders(session, provider);
  135. // 空字符串应该被保留(使用 ?? 而非 ||)
  136. expect(resultHeaders.get("user-agent")).toBe("");
  137. });
  138. });
  139. describe("ProxyForwarder - buildGeminiHeaders headers passthrough", () => {
  140. it("应该透传 user-agent,并覆盖上游 x-goog-api-key(API key 模式)", () => {
  141. const session = createSession({
  142. userAgent: "Original-UA/1.0",
  143. headers: new Headers([
  144. ["user-agent", "Original-UA/1.0"],
  145. ["x-api-key", "proxy-user-key-should-not-leak"],
  146. ["x-goog-api-key", "proxy-user-key-google-should-not-leak"],
  147. ["authorization", "Bearer proxy-user-bearer-should-not-leak"],
  148. ]),
  149. });
  150. const provider = createGeminiProvider("gemini");
  151. const { buildGeminiHeaders } = ProxyForwarder as unknown as {
  152. buildGeminiHeaders: (
  153. session: ProxySession,
  154. provider: Provider,
  155. baseUrl: string,
  156. accessToken: string,
  157. isApiKey: boolean
  158. ) => Headers;
  159. };
  160. const resultHeaders = buildGeminiHeaders(
  161. session,
  162. provider,
  163. "https://generativelanguage.googleapis.com/v1beta",
  164. "upstream-api-key",
  165. true
  166. );
  167. expect(resultHeaders.get("user-agent")).toBe("Original-UA/1.0");
  168. expect(resultHeaders.get("x-api-key")).toBeNull();
  169. expect(resultHeaders.get("authorization")).toBeNull();
  170. expect(resultHeaders.get("x-goog-api-key")).toBe("upstream-api-key");
  171. expect(resultHeaders.get("accept-encoding")).toBe("identity");
  172. expect(resultHeaders.get("content-type")).toBe("application/json");
  173. expect(resultHeaders.get("host")).toBe("generativelanguage.googleapis.com");
  174. });
  175. it("应该允许过滤器修改 user-agent(Gemini provider)", () => {
  176. const session = createSession({
  177. userAgent: "Original-UA/1.0",
  178. headers: new Headers([["user-agent", "Filtered-UA/2.0"]]),
  179. });
  180. (session as any).originalHeaders = new Headers([["user-agent", "Original-UA/1.0"]]);
  181. const provider = createGeminiProvider("gemini");
  182. const { buildGeminiHeaders } = ProxyForwarder as unknown as {
  183. buildGeminiHeaders: (
  184. session: ProxySession,
  185. provider: Provider,
  186. baseUrl: string,
  187. accessToken: string,
  188. isApiKey: boolean
  189. ) => Headers;
  190. };
  191. const resultHeaders = buildGeminiHeaders(
  192. session,
  193. provider,
  194. "https://generativelanguage.googleapis.com/v1beta",
  195. "upstream-api-key",
  196. true
  197. );
  198. expect(resultHeaders.get("user-agent")).toBe("Filtered-UA/2.0");
  199. });
  200. it("应该在过滤器删除 user-agent 时回退到原始 userAgent(Gemini provider)", () => {
  201. const session = createSession({
  202. userAgent: "Original-UA/1.0",
  203. headers: new Headers(), // user-agent 被删除
  204. });
  205. (session as any).originalHeaders = new Headers([["user-agent", "Original-UA/1.0"]]);
  206. const provider = createGeminiProvider("gemini");
  207. const { buildGeminiHeaders } = ProxyForwarder as unknown as {
  208. buildGeminiHeaders: (
  209. session: ProxySession,
  210. provider: Provider,
  211. baseUrl: string,
  212. accessToken: string,
  213. isApiKey: boolean
  214. ) => Headers;
  215. };
  216. const resultHeaders = buildGeminiHeaders(
  217. session,
  218. provider,
  219. "https://generativelanguage.googleapis.com/v1beta",
  220. "upstream-api-key",
  221. true
  222. );
  223. expect(resultHeaders.get("user-agent")).toBe("Original-UA/1.0");
  224. });
  225. it("应该使用 Authorization Bearer(OAuth 模式)并移除 x-goog-api-key", () => {
  226. const session = createSession({
  227. userAgent: "Original-UA/1.0",
  228. headers: new Headers([
  229. ["user-agent", "Original-UA/1.0"],
  230. ["x-goog-api-key", "proxy-user-key-google-should-not-leak"],
  231. ]),
  232. });
  233. const provider = createGeminiProvider("gemini");
  234. const { buildGeminiHeaders } = ProxyForwarder as unknown as {
  235. buildGeminiHeaders: (
  236. session: ProxySession,
  237. provider: Provider,
  238. baseUrl: string,
  239. accessToken: string,
  240. isApiKey: boolean
  241. ) => Headers;
  242. };
  243. const resultHeaders = buildGeminiHeaders(
  244. session,
  245. provider,
  246. "https://generativelanguage.googleapis.com/v1beta",
  247. "upstream-oauth-token",
  248. false
  249. );
  250. expect(resultHeaders.get("authorization")).toBe("Bearer upstream-oauth-token");
  251. expect(resultHeaders.get("x-goog-api-key")).toBeNull();
  252. });
  253. it("gemini-cli 应该注入 x-goog-api-client 头", () => {
  254. const session = createSession({
  255. userAgent: "Original-UA/1.0",
  256. headers: new Headers([["user-agent", "Original-UA/1.0"]]),
  257. });
  258. const provider = createGeminiProvider("gemini-cli");
  259. const { buildGeminiHeaders } = ProxyForwarder as unknown as {
  260. buildGeminiHeaders: (
  261. session: ProxySession,
  262. provider: Provider,
  263. baseUrl: string,
  264. accessToken: string,
  265. isApiKey: boolean
  266. ) => Headers;
  267. };
  268. const resultHeaders = buildGeminiHeaders(
  269. session,
  270. provider,
  271. "https://cloudcode-pa.googleapis.com/v1internal",
  272. "upstream-oauth-token",
  273. false
  274. );
  275. expect(resultHeaders.get("x-goog-api-client")).toBe("GeminiCLI/1.0");
  276. });
  277. });