ws-probe.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  1. /**
  2. * WebSocket Provider Probe Tests
  3. *
  4. * Tests probeProviderWebSocket which wraps OutboundWsAdapter
  5. * to test whether a provider supports Responses WebSocket mode.
  6. */
  7. import { beforeEach, describe, expect, it, vi } from "vitest";
  8. // ---------------------------------------------------------------------------
  9. // Hoisted mock state (survives vitest mockReset)
  10. // ---------------------------------------------------------------------------
  11. const { getLastAdapter, setLastAdapter, resetAdapter, getCtorArgs, resetCtorArgs } = vi.hoisted(
  12. () => {
  13. type MockAdapter = {
  14. executeTurn: ReturnType<typeof vi.fn>;
  15. close: ReturnType<typeof vi.fn>;
  16. };
  17. let adapter: MockAdapter | null = null;
  18. let ctorArgs: unknown[] = [];
  19. return {
  20. getLastAdapter: (): MockAdapter | null => adapter,
  21. setLastAdapter: (a: MockAdapter) => {
  22. adapter = a;
  23. },
  24. resetAdapter: () => {
  25. adapter = {
  26. executeTurn: vi.fn(),
  27. close: vi.fn(),
  28. };
  29. },
  30. getCtorArgs: () => ctorArgs,
  31. resetCtorArgs: () => {
  32. ctorArgs = [];
  33. },
  34. };
  35. }
  36. );
  37. // ---------------------------------------------------------------------------
  38. // Mock: OutboundWsAdapter (class-based, resilient to mockReset)
  39. // ---------------------------------------------------------------------------
  40. vi.mock("@/app/v1/_lib/ws/outbound-adapter", () => {
  41. class MockOutboundWsAdapter {
  42. executeTurn: ReturnType<typeof vi.fn>;
  43. close: ReturnType<typeof vi.fn>;
  44. constructor(options: unknown) {
  45. getCtorArgs().push(options);
  46. const mock = getLastAdapter()!;
  47. this.executeTurn = mock.executeTurn;
  48. this.close = mock.close;
  49. setLastAdapter(mock);
  50. }
  51. }
  52. return { OutboundWsAdapter: MockOutboundWsAdapter };
  53. });
  54. // ---------------------------------------------------------------------------
  55. // Mock: transport-classifier (has "server-only" import)
  56. // ---------------------------------------------------------------------------
  57. vi.mock("@/app/v1/_lib/proxy/transport-classifier", () => ({
  58. toWebSocketUrl: (url: string) =>
  59. `${url.replace("https://", "wss://").replace(/\/$/, "")}/v1/responses`,
  60. }));
  61. // ---------------------------------------------------------------------------
  62. // Mock: logger
  63. // ---------------------------------------------------------------------------
  64. vi.mock("@/lib/logger", () => ({
  65. logger: {
  66. debug: vi.fn(),
  67. info: vi.fn(),
  68. warn: vi.fn(),
  69. error: vi.fn(),
  70. },
  71. }));
  72. // ---------------------------------------------------------------------------
  73. // Import SUT (after all mocks)
  74. // ---------------------------------------------------------------------------
  75. import {
  76. probeProviderWebSocket,
  77. type WsProbeConfig,
  78. type WsProbeResult,
  79. } from "@/lib/provider-testing/ws-probe";
  80. // ---------------------------------------------------------------------------
  81. // Helpers
  82. // ---------------------------------------------------------------------------
  83. function defaultConfig(overrides?: Partial<WsProbeConfig>): WsProbeConfig {
  84. return {
  85. providerUrl: "https://api.openai.com",
  86. apiKey: "sk-test-123",
  87. ...overrides,
  88. };
  89. }
  90. // ---------------------------------------------------------------------------
  91. // Tests
  92. // ---------------------------------------------------------------------------
  93. describe("probeProviderWebSocket", () => {
  94. beforeEach(() => {
  95. resetAdapter();
  96. resetCtorArgs();
  97. });
  98. // =========================================================================
  99. // 1. Success case
  100. // =========================================================================
  101. it("reports success when WS handshake and terminal event succeed", async () => {
  102. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  103. completed: true,
  104. terminalType: "response.completed",
  105. handshakeMs: 42,
  106. events: [
  107. { type: "response.output_text.delta", data: {} },
  108. { type: "response.completed", data: {} },
  109. ],
  110. model: "gpt-4o",
  111. usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 },
  112. });
  113. const result = await probeProviderWebSocket(defaultConfig());
  114. expect(result.wsSupported).toBe(true);
  115. expect(result.wsTransport).toBe("websocket");
  116. expect(result.wsHandshakeMs).toBe(42);
  117. expect(result.wsEventCount).toBe(2);
  118. expect(result.wsTerminalModel).toBe("gpt-4o");
  119. expect(result.wsTerminalUsage).toEqual({
  120. input_tokens: 100,
  121. output_tokens: 50,
  122. total_tokens: 150,
  123. });
  124. });
  125. // =========================================================================
  126. // 2. Handshake rejected (non-101)
  127. // =========================================================================
  128. it("reports 'unsupported' when WS handshake is rejected (non-101 response)", async () => {
  129. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  130. completed: false,
  131. events: [],
  132. // No handshakeMs -> handshake never completed
  133. error: new Error("Unexpected server response: 403"),
  134. });
  135. const result = await probeProviderWebSocket(defaultConfig());
  136. expect(result.wsSupported).toBe(false);
  137. expect(result.wsTransport).toBe("unsupported");
  138. expect(result.wsFallbackReason).toContain("403");
  139. });
  140. // =========================================================================
  141. // 3. Handshake timeout
  142. // =========================================================================
  143. it("reports 'unsupported' when WS handshake times out", async () => {
  144. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  145. completed: false,
  146. events: [],
  147. // No handshakeMs -> handshake never completed
  148. error: new Error("Handshake timeout: 10000ms"),
  149. });
  150. const result = await probeProviderWebSocket(defaultConfig());
  151. expect(result.wsSupported).toBe(false);
  152. expect(result.wsTransport).toBe("unsupported");
  153. expect(result.wsFallbackReason).toContain("Handshake timeout");
  154. });
  155. // =========================================================================
  156. // 4. Captures handshake latency, event count, terminal model
  157. // =========================================================================
  158. it("captures handshake latency, event count, terminal model", async () => {
  159. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  160. completed: true,
  161. terminalType: "response.completed",
  162. handshakeMs: 87,
  163. events: [
  164. { type: "response.output_text.delta", data: {} },
  165. { type: "response.output_text.delta", data: {} },
  166. { type: "response.output_text.delta", data: {} },
  167. { type: "response.completed", data: {} },
  168. ],
  169. model: "gpt-5-codex",
  170. usage: { input_tokens: 200, output_tokens: 100, total_tokens: 300 },
  171. });
  172. const result = await probeProviderWebSocket(defaultConfig());
  173. expect(result.wsHandshakeMs).toBe(87);
  174. expect(result.wsEventCount).toBe(4);
  175. expect(result.wsTerminalModel).toBe("gpt-5-codex");
  176. });
  177. // =========================================================================
  178. // 5. Captures usage from terminal event
  179. // =========================================================================
  180. it("captures usage from terminal event", async () => {
  181. const usage = {
  182. input_tokens: 500,
  183. output_tokens: 200,
  184. total_tokens: 700,
  185. output_tokens_details: { reasoning_tokens: 50 },
  186. };
  187. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  188. completed: true,
  189. terminalType: "response.completed",
  190. handshakeMs: 50,
  191. events: [{ type: "response.completed", data: {} }],
  192. model: "gpt-4o",
  193. usage,
  194. });
  195. const result = await probeProviderWebSocket(defaultConfig());
  196. expect(result.wsTerminalUsage).toEqual(usage);
  197. });
  198. // =========================================================================
  199. // 6. Reports fallback reason when WS fails with recoverable error
  200. // =========================================================================
  201. it("reports fallback reason when WS fails with recoverable error", async () => {
  202. // Handshake succeeded (handshakeMs present) but server returned an error frame
  203. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  204. completed: false,
  205. handshakeMs: 30,
  206. events: [{ type: "error", data: {} }],
  207. error: {
  208. error: {
  209. type: "invalid_request_error",
  210. message: "Model not found",
  211. code: "invalid_model",
  212. },
  213. },
  214. });
  215. const result = await probeProviderWebSocket(defaultConfig());
  216. // Handshake succeeded -> provider supports WS
  217. expect(result.wsSupported).toBe(true);
  218. expect(result.wsTransport).toBe("websocket");
  219. expect(result.wsFallbackReason).toBeDefined();
  220. expect(result.wsHandshakeMs).toBe(30);
  221. expect(result.wsEventCount).toBe(1);
  222. });
  223. // =========================================================================
  224. // 7. WsProbeResult type has all required fields
  225. // =========================================================================
  226. it("WsProbeResult type has all required fields", () => {
  227. // Compile-time verification: this must compile without errors
  228. const successResult: WsProbeResult = {
  229. wsSupported: true,
  230. wsTransport: "websocket",
  231. wsHandshakeMs: 100,
  232. wsEventCount: 5,
  233. wsFallbackReason: undefined,
  234. wsTerminalModel: "gpt-4o",
  235. wsTerminalUsage: { input_tokens: 10, output_tokens: 5 },
  236. };
  237. const unsupportedResult: WsProbeResult = {
  238. wsSupported: false,
  239. wsTransport: "unsupported",
  240. wsFallbackReason: "Connection refused",
  241. };
  242. const fallbackResult: WsProbeResult = {
  243. wsSupported: false,
  244. wsTransport: "http_fallback",
  245. wsFallbackReason: "Provider does not support WS",
  246. };
  247. // Runtime check: all required fields exist
  248. expect(successResult).toHaveProperty("wsSupported");
  249. expect(successResult).toHaveProperty("wsTransport");
  250. expect(successResult).toHaveProperty("wsHandshakeMs");
  251. expect(successResult).toHaveProperty("wsEventCount");
  252. expect(successResult).toHaveProperty("wsTerminalModel");
  253. expect(successResult).toHaveProperty("wsTerminalUsage");
  254. expect(unsupportedResult).toHaveProperty("wsSupported");
  255. expect(unsupportedResult).toHaveProperty("wsTransport");
  256. expect(unsupportedResult).toHaveProperty("wsFallbackReason");
  257. // Transport enum values
  258. expect(["websocket", "http_fallback", "unsupported"]).toContain(successResult.wsTransport);
  259. expect(["websocket", "http_fallback", "unsupported"]).toContain(unsupportedResult.wsTransport);
  260. expect(["websocket", "http_fallback", "unsupported"]).toContain(fallbackResult.wsTransport);
  261. });
  262. // =========================================================================
  263. // 8. Works with cx_base preset data
  264. // =========================================================================
  265. it("works with cx_base preset data (model extraction, input formatting)", async () => {
  266. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  267. completed: true,
  268. terminalType: "response.completed",
  269. handshakeMs: 60,
  270. events: [{ type: "response.completed", data: {} }],
  271. model: "gpt-5-codex",
  272. usage: { input_tokens: 100, output_tokens: 20, total_tokens: 120 },
  273. });
  274. const result = await probeProviderWebSocket(defaultConfig({ preset: "cx_base" }));
  275. // Verify the adapter was created with correct options
  276. const ctorArgs = getCtorArgs();
  277. expect(ctorArgs[0]).toEqual(
  278. expect.objectContaining({
  279. providerBaseUrl: "https://api.openai.com",
  280. apiKey: "sk-test-123",
  281. })
  282. );
  283. // Verify executeTurn was called with preset payload
  284. const adapter = getLastAdapter()!;
  285. const payload = adapter.executeTurn.mock.calls[0][0] as Record<string, unknown>;
  286. expect(payload.model).toBe("gpt-5-codex"); // cx_base default model
  287. expect(payload).toHaveProperty("input");
  288. expect(payload).toHaveProperty("instructions");
  289. // Verify result
  290. expect(result.wsSupported).toBe(true);
  291. expect(result.wsTerminalModel).toBe("gpt-5-codex");
  292. });
  293. // =========================================================================
  294. // Additional edge cases
  295. // =========================================================================
  296. it("uses custom model when provided with preset", async () => {
  297. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  298. completed: true,
  299. terminalType: "response.completed",
  300. handshakeMs: 50,
  301. events: [{ type: "response.completed", data: {} }],
  302. model: "o4-mini",
  303. usage: { input_tokens: 50, output_tokens: 10, total_tokens: 60 },
  304. });
  305. await probeProviderWebSocket(defaultConfig({ preset: "cx_base", model: "o4-mini" }));
  306. const payload = getLastAdapter()!.executeTurn.mock.calls[0][0] as Record<string, unknown>;
  307. expect(payload.model).toBe("o4-mini");
  308. });
  309. it("handles connection refused error as unsupported", async () => {
  310. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  311. completed: false,
  312. events: [],
  313. error: new Error("connect ECONNREFUSED 127.0.0.1:443"),
  314. });
  315. const result = await probeProviderWebSocket(defaultConfig());
  316. expect(result.wsSupported).toBe(false);
  317. expect(result.wsTransport).toBe("unsupported");
  318. expect(result.wsFallbackReason).toContain("ECONNREFUSED");
  319. });
  320. it("handles executeTurn rejection gracefully", async () => {
  321. const adapter = getLastAdapter()!;
  322. adapter.executeTurn.mockRejectedValueOnce(new Error("Unexpected internal error"));
  323. const result = await probeProviderWebSocket(defaultConfig());
  324. expect(result.wsSupported).toBe(false);
  325. expect(result.wsTransport).toBe("unsupported");
  326. expect(result.wsFallbackReason).toContain("Unexpected internal error");
  327. // Adapter should be closed on error
  328. expect(adapter.close).toHaveBeenCalled();
  329. });
  330. it("handles completed turn with no usage gracefully", async () => {
  331. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  332. completed: true,
  333. terminalType: "response.completed",
  334. handshakeMs: 100,
  335. events: [{ type: "response.completed", data: {} }],
  336. model: "gpt-4o",
  337. // No usage field
  338. });
  339. const result = await probeProviderWebSocket(defaultConfig());
  340. expect(result.wsSupported).toBe(true);
  341. expect(result.wsTransport).toBe("websocket");
  342. expect(result.wsTerminalModel).toBe("gpt-4o");
  343. expect(result.wsTerminalUsage).toBeUndefined();
  344. });
  345. it("defaults to cx_base preset when none specified", async () => {
  346. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  347. completed: true,
  348. terminalType: "response.completed",
  349. handshakeMs: 50,
  350. events: [{ type: "response.completed", data: {} }],
  351. model: "gpt-5-codex",
  352. });
  353. await probeProviderWebSocket(defaultConfig());
  354. const payload = getLastAdapter()!.executeTurn.mock.calls[0][0] as Record<string, unknown>;
  355. // cx_base default model
  356. expect(payload.model).toBe("gpt-5-codex");
  357. // cx_base has instructions field
  358. expect(payload).toHaveProperty("instructions");
  359. });
  360. it("passes timeout config to adapter options", async () => {
  361. getLastAdapter()!.executeTurn.mockResolvedValueOnce({
  362. completed: true,
  363. terminalType: "response.completed",
  364. handshakeMs: 50,
  365. events: [{ type: "response.completed", data: {} }],
  366. model: "gpt-4o",
  367. });
  368. await probeProviderWebSocket(defaultConfig({ timeoutMs: 5000 }));
  369. // Verify adapter was configured with timeout-derived values
  370. const ctorArgs = getCtorArgs();
  371. const options = ctorArgs[0] as Record<string, unknown>;
  372. expect(options).toHaveProperty("handshakeTimeoutMs");
  373. expect(options).toHaveProperty("idleTimeoutMs");
  374. expect(options.handshakeTimeoutMs).toBeLessThanOrEqual(5000);
  375. expect(options.idleTimeoutMs).toBe(5000);
  376. });
  377. });