| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452 |
- /**
- * WebSocket Provider Probe Tests
- *
- * Tests probeProviderWebSocket which wraps OutboundWsAdapter
- * to test whether a provider supports Responses WebSocket mode.
- */
- import { beforeEach, describe, expect, it, vi } from "vitest";
- // ---------------------------------------------------------------------------
- // Hoisted mock state (survives vitest mockReset)
- // ---------------------------------------------------------------------------
- const { getLastAdapter, setLastAdapter, resetAdapter, getCtorArgs, resetCtorArgs } = vi.hoisted(
- () => {
- type MockAdapter = {
- executeTurn: ReturnType<typeof vi.fn>;
- close: ReturnType<typeof vi.fn>;
- };
- let adapter: MockAdapter | null = null;
- let ctorArgs: unknown[] = [];
- return {
- getLastAdapter: (): MockAdapter | null => adapter,
- setLastAdapter: (a: MockAdapter) => {
- adapter = a;
- },
- resetAdapter: () => {
- adapter = {
- executeTurn: vi.fn(),
- close: vi.fn(),
- };
- },
- getCtorArgs: () => ctorArgs,
- resetCtorArgs: () => {
- ctorArgs = [];
- },
- };
- }
- );
- // ---------------------------------------------------------------------------
- // Mock: OutboundWsAdapter (class-based, resilient to mockReset)
- // ---------------------------------------------------------------------------
- vi.mock("@/app/v1/_lib/ws/outbound-adapter", () => {
- class MockOutboundWsAdapter {
- executeTurn: ReturnType<typeof vi.fn>;
- close: ReturnType<typeof vi.fn>;
- constructor(options: unknown) {
- getCtorArgs().push(options);
- const mock = getLastAdapter()!;
- this.executeTurn = mock.executeTurn;
- this.close = mock.close;
- setLastAdapter(mock);
- }
- }
- return { OutboundWsAdapter: MockOutboundWsAdapter };
- });
- // ---------------------------------------------------------------------------
- // Mock: transport-classifier (has "server-only" import)
- // ---------------------------------------------------------------------------
- vi.mock("@/app/v1/_lib/proxy/transport-classifier", () => ({
- toWebSocketUrl: (url: string) =>
- `${url.replace("https://", "wss://").replace(/\/$/, "")}/v1/responses`,
- }));
- // ---------------------------------------------------------------------------
- // Mock: logger
- // ---------------------------------------------------------------------------
- vi.mock("@/lib/logger", () => ({
- logger: {
- debug: vi.fn(),
- info: vi.fn(),
- warn: vi.fn(),
- error: vi.fn(),
- },
- }));
- // ---------------------------------------------------------------------------
- // Import SUT (after all mocks)
- // ---------------------------------------------------------------------------
- import {
- probeProviderWebSocket,
- type WsProbeConfig,
- type WsProbeResult,
- } from "@/lib/provider-testing/ws-probe";
- // ---------------------------------------------------------------------------
- // Helpers
- // ---------------------------------------------------------------------------
- function defaultConfig(overrides?: Partial<WsProbeConfig>): WsProbeConfig {
- return {
- providerUrl: "https://api.openai.com",
- apiKey: "sk-test-123",
- ...overrides,
- };
- }
- // ---------------------------------------------------------------------------
- // Tests
- // ---------------------------------------------------------------------------
- describe("probeProviderWebSocket", () => {
- beforeEach(() => {
- resetAdapter();
- resetCtorArgs();
- });
- // =========================================================================
- // 1. Success case
- // =========================================================================
- it("reports success when WS handshake and terminal event succeed", async () => {
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: true,
- terminalType: "response.completed",
- handshakeMs: 42,
- events: [
- { type: "response.output_text.delta", data: {} },
- { type: "response.completed", data: {} },
- ],
- model: "gpt-4o",
- usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 },
- });
- const result = await probeProviderWebSocket(defaultConfig());
- expect(result.wsSupported).toBe(true);
- expect(result.wsTransport).toBe("websocket");
- expect(result.wsHandshakeMs).toBe(42);
- expect(result.wsEventCount).toBe(2);
- expect(result.wsTerminalModel).toBe("gpt-4o");
- expect(result.wsTerminalUsage).toEqual({
- input_tokens: 100,
- output_tokens: 50,
- total_tokens: 150,
- });
- });
- // =========================================================================
- // 2. Handshake rejected (non-101)
- // =========================================================================
- it("reports 'unsupported' when WS handshake is rejected (non-101 response)", async () => {
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: false,
- events: [],
- // No handshakeMs -> handshake never completed
- error: new Error("Unexpected server response: 403"),
- });
- const result = await probeProviderWebSocket(defaultConfig());
- expect(result.wsSupported).toBe(false);
- expect(result.wsTransport).toBe("unsupported");
- expect(result.wsFallbackReason).toContain("403");
- });
- // =========================================================================
- // 3. Handshake timeout
- // =========================================================================
- it("reports 'unsupported' when WS handshake times out", async () => {
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: false,
- events: [],
- // No handshakeMs -> handshake never completed
- error: new Error("Handshake timeout: 10000ms"),
- });
- const result = await probeProviderWebSocket(defaultConfig());
- expect(result.wsSupported).toBe(false);
- expect(result.wsTransport).toBe("unsupported");
- expect(result.wsFallbackReason).toContain("Handshake timeout");
- });
- // =========================================================================
- // 4. Captures handshake latency, event count, terminal model
- // =========================================================================
- it("captures handshake latency, event count, terminal model", async () => {
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: true,
- terminalType: "response.completed",
- handshakeMs: 87,
- events: [
- { type: "response.output_text.delta", data: {} },
- { type: "response.output_text.delta", data: {} },
- { type: "response.output_text.delta", data: {} },
- { type: "response.completed", data: {} },
- ],
- model: "gpt-5-codex",
- usage: { input_tokens: 200, output_tokens: 100, total_tokens: 300 },
- });
- const result = await probeProviderWebSocket(defaultConfig());
- expect(result.wsHandshakeMs).toBe(87);
- expect(result.wsEventCount).toBe(4);
- expect(result.wsTerminalModel).toBe("gpt-5-codex");
- });
- // =========================================================================
- // 5. Captures usage from terminal event
- // =========================================================================
- it("captures usage from terminal event", async () => {
- const usage = {
- input_tokens: 500,
- output_tokens: 200,
- total_tokens: 700,
- output_tokens_details: { reasoning_tokens: 50 },
- };
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: true,
- terminalType: "response.completed",
- handshakeMs: 50,
- events: [{ type: "response.completed", data: {} }],
- model: "gpt-4o",
- usage,
- });
- const result = await probeProviderWebSocket(defaultConfig());
- expect(result.wsTerminalUsage).toEqual(usage);
- });
- // =========================================================================
- // 6. Reports fallback reason when WS fails with recoverable error
- // =========================================================================
- it("reports fallback reason when WS fails with recoverable error", async () => {
- // Handshake succeeded (handshakeMs present) but server returned an error frame
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: false,
- handshakeMs: 30,
- events: [{ type: "error", data: {} }],
- error: {
- error: {
- type: "invalid_request_error",
- message: "Model not found",
- code: "invalid_model",
- },
- },
- });
- const result = await probeProviderWebSocket(defaultConfig());
- // Handshake succeeded -> provider supports WS
- expect(result.wsSupported).toBe(true);
- expect(result.wsTransport).toBe("websocket");
- expect(result.wsFallbackReason).toBeDefined();
- expect(result.wsHandshakeMs).toBe(30);
- expect(result.wsEventCount).toBe(1);
- });
- // =========================================================================
- // 7. WsProbeResult type has all required fields
- // =========================================================================
- it("WsProbeResult type has all required fields", () => {
- // Compile-time verification: this must compile without errors
- const successResult: WsProbeResult = {
- wsSupported: true,
- wsTransport: "websocket",
- wsHandshakeMs: 100,
- wsEventCount: 5,
- wsFallbackReason: undefined,
- wsTerminalModel: "gpt-4o",
- wsTerminalUsage: { input_tokens: 10, output_tokens: 5 },
- };
- const unsupportedResult: WsProbeResult = {
- wsSupported: false,
- wsTransport: "unsupported",
- wsFallbackReason: "Connection refused",
- };
- const fallbackResult: WsProbeResult = {
- wsSupported: false,
- wsTransport: "http_fallback",
- wsFallbackReason: "Provider does not support WS",
- };
- // Runtime check: all required fields exist
- expect(successResult).toHaveProperty("wsSupported");
- expect(successResult).toHaveProperty("wsTransport");
- expect(successResult).toHaveProperty("wsHandshakeMs");
- expect(successResult).toHaveProperty("wsEventCount");
- expect(successResult).toHaveProperty("wsTerminalModel");
- expect(successResult).toHaveProperty("wsTerminalUsage");
- expect(unsupportedResult).toHaveProperty("wsSupported");
- expect(unsupportedResult).toHaveProperty("wsTransport");
- expect(unsupportedResult).toHaveProperty("wsFallbackReason");
- // Transport enum values
- expect(["websocket", "http_fallback", "unsupported"]).toContain(successResult.wsTransport);
- expect(["websocket", "http_fallback", "unsupported"]).toContain(unsupportedResult.wsTransport);
- expect(["websocket", "http_fallback", "unsupported"]).toContain(fallbackResult.wsTransport);
- });
- // =========================================================================
- // 8. Works with cx_base preset data
- // =========================================================================
- it("works with cx_base preset data (model extraction, input formatting)", async () => {
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: true,
- terminalType: "response.completed",
- handshakeMs: 60,
- events: [{ type: "response.completed", data: {} }],
- model: "gpt-5-codex",
- usage: { input_tokens: 100, output_tokens: 20, total_tokens: 120 },
- });
- const result = await probeProviderWebSocket(defaultConfig({ preset: "cx_base" }));
- // Verify the adapter was created with correct options
- const ctorArgs = getCtorArgs();
- expect(ctorArgs[0]).toEqual(
- expect.objectContaining({
- providerBaseUrl: "https://api.openai.com",
- apiKey: "sk-test-123",
- })
- );
- // Verify executeTurn was called with preset payload
- const adapter = getLastAdapter()!;
- const payload = adapter.executeTurn.mock.calls[0][0] as Record<string, unknown>;
- expect(payload.model).toBe("gpt-5-codex"); // cx_base default model
- expect(payload).toHaveProperty("input");
- expect(payload).toHaveProperty("instructions");
- // Verify result
- expect(result.wsSupported).toBe(true);
- expect(result.wsTerminalModel).toBe("gpt-5-codex");
- });
- // =========================================================================
- // Additional edge cases
- // =========================================================================
- it("uses custom model when provided with preset", async () => {
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: true,
- terminalType: "response.completed",
- handshakeMs: 50,
- events: [{ type: "response.completed", data: {} }],
- model: "o4-mini",
- usage: { input_tokens: 50, output_tokens: 10, total_tokens: 60 },
- });
- await probeProviderWebSocket(defaultConfig({ preset: "cx_base", model: "o4-mini" }));
- const payload = getLastAdapter()!.executeTurn.mock.calls[0][0] as Record<string, unknown>;
- expect(payload.model).toBe("o4-mini");
- });
- it("handles connection refused error as unsupported", async () => {
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: false,
- events: [],
- error: new Error("connect ECONNREFUSED 127.0.0.1:443"),
- });
- const result = await probeProviderWebSocket(defaultConfig());
- expect(result.wsSupported).toBe(false);
- expect(result.wsTransport).toBe("unsupported");
- expect(result.wsFallbackReason).toContain("ECONNREFUSED");
- });
- it("handles executeTurn rejection gracefully", async () => {
- const adapter = getLastAdapter()!;
- adapter.executeTurn.mockRejectedValueOnce(new Error("Unexpected internal error"));
- const result = await probeProviderWebSocket(defaultConfig());
- expect(result.wsSupported).toBe(false);
- expect(result.wsTransport).toBe("unsupported");
- expect(result.wsFallbackReason).toContain("Unexpected internal error");
- // Adapter should be closed on error
- expect(adapter.close).toHaveBeenCalled();
- });
- it("handles completed turn with no usage gracefully", async () => {
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: true,
- terminalType: "response.completed",
- handshakeMs: 100,
- events: [{ type: "response.completed", data: {} }],
- model: "gpt-4o",
- // No usage field
- });
- const result = await probeProviderWebSocket(defaultConfig());
- expect(result.wsSupported).toBe(true);
- expect(result.wsTransport).toBe("websocket");
- expect(result.wsTerminalModel).toBe("gpt-4o");
- expect(result.wsTerminalUsage).toBeUndefined();
- });
- it("defaults to cx_base preset when none specified", async () => {
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: true,
- terminalType: "response.completed",
- handshakeMs: 50,
- events: [{ type: "response.completed", data: {} }],
- model: "gpt-5-codex",
- });
- await probeProviderWebSocket(defaultConfig());
- const payload = getLastAdapter()!.executeTurn.mock.calls[0][0] as Record<string, unknown>;
- // cx_base default model
- expect(payload.model).toBe("gpt-5-codex");
- // cx_base has instructions field
- expect(payload).toHaveProperty("instructions");
- });
- it("passes timeout config to adapter options", async () => {
- getLastAdapter()!.executeTurn.mockResolvedValueOnce({
- completed: true,
- terminalType: "response.completed",
- handshakeMs: 50,
- events: [{ type: "response.completed", data: {} }],
- model: "gpt-4o",
- });
- await probeProviderWebSocket(defaultConfig({ timeoutMs: 5000 }));
- // Verify adapter was configured with timeout-derived values
- const ctorArgs = getCtorArgs();
- const options = ctorArgs[0] as Record<string, unknown>;
- expect(options).toHaveProperty("handshakeTimeoutMs");
- expect(options).toHaveProperty("idleTimeoutMs");
- expect(options.handshakeTimeoutMs).toBeLessThanOrEqual(5000);
- expect(options.idleTimeoutMs).toBe(5000);
- });
- });
|