Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .fernignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ tests/unit/compat-aliases.test.ts
tests/unit/custom-baseurl.test.ts
tests/unit/custom-client.test.ts
tests/unit/error-handling.test.ts
tests/unit/keywords-nova3-guard.test.ts
tests/unit/multiple-keyterms.test.ts
tests/unit/nodejs-version-compatibility.test.ts
tests/unit/transport-factory.test.ts
Expand Down
35 changes: 35 additions & 0 deletions src/CustomClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { V2Socket as ListenV2Socket } from "./api/resources/listen/resources/v2/
import { V1Socket as SpeakV1Socket } from "./api/resources/speak/resources/v1/client/Socket.js";
import { mergeHeaders } from "./core/headers.js";
import { fromJson } from "./core/json.js";
import { BadRequestError } from "./api/errors/index.js";
import * as core from "./core/index.js";
import * as websocketEvents from "./core/websocket/events.js";
import * as environments from "./environments.js";
Expand Down Expand Up @@ -403,6 +404,31 @@ function buildQueryParams(args: Record<string, unknown>): Record<string, unknown
return result;
}

/**
* Nova-3 dropped the legacy `keywords` parameter in favor of `keyterm`, and the API rejects the
* combination with a 400. REST surfaces that error, but the streaming endpoint just closes the
* socket cleanly (code 1000, no reason, no error event) — so it fails silently and the socket
* never opens. Detecting the combination up front lets the client throw the same error REST
* already does, in both Node and the browser.
*
* Exported for unit testing; not part of the public API.
*/
export function isUnsupportedNova3Keywords(model: unknown, keywords: unknown): boolean {
if (typeof model !== "string" || !model.startsWith("nova-3")) {
return false;
}
if (keywords == null) {
return false;
}
if (typeof keywords === "string" && keywords.length === 0) {
return false;
}
if (Array.isArray(keywords) && keywords.length === 0) {
return false;
}
return true;
}

function normalizeProtocols(protocols?: string | string[]): string[] {
if (protocols == null) {
return [];
Expand Down Expand Up @@ -1210,6 +1236,15 @@ class WrappedAgentV1Socket extends AgentV1Socket {
*/
class WrappedListenV1Client extends ListenV1Client {
public async connect(args: Omit<ListenV1Client.ConnectArgs, "Authorization"> & { Authorization?: string }): Promise<ListenV1Socket> {
// Surface the same 400 the REST path throws for nova-3 + keywords, instead of letting the
// streaming handshake fail silently with a socket that never opens.
if (isUnsupportedNova3Keywords(args.model, args.keywords)) {
throw new BadRequestError({
err_code: "INVALID_QUERY_PARAMETER",
err_msg: "Keywords are not supported for Nova-3. Please use `keyterm` instead.",
});
}

const { headers, protocols, debug, reconnectAttempts, connectionTimeoutInSeconds, abortSignal } = args;

const socket = await createWebSocketConnection({
Expand Down
77 changes: 77 additions & 0 deletions tests/unit/keywords-nova3-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import { DeepgramClient, DeepgramError } from "../../src/index.js";
import { isUnsupportedNova3Keywords } from "../../src/CustomClient.js";
import { BadRequestError } from "../../src/api/errors/index.js";

/**
* Regression test for deepgram-js-sdk#474 / #470.
*
* Nova-3 models dropped the legacy `keywords` query parameter in favor of `keyterm`. The API
* rejects the combination with a 400 on both REST and streaming, but the streaming WebSocket path
* used to swallow the rejected handshake — the socket silently never opened, with no error
* (`open=false, close=1000, err=none`). The SDK now rejects up front in
* `WrappedListenV1Client.connect` with the *same* `BadRequestError` the REST path already throws,
* so a developer gets an actionable error in both Node and the browser.
*/
describe("listen.v1 nova-3 + keywords guard (#474)", () => {
describe("connect() rejects for nova-3 + keywords", () => {
for (const model of ["nova-3", "nova-3-general", "nova-3-medical"]) {
it(`rejects with a 400 BadRequestError for ${model}`, async () => {
const client = new DeepgramClient({ apiKey: "test-key" });

await expect(client.listen.v1.connect({ model, keywords: "deepgram" })).rejects.toMatchObject({
name: "BadRequestError",
statusCode: 400,
});
});
}

it("createConnection() rejects the same way (it delegates to connect)", async () => {
const client = new DeepgramClient({ apiKey: "test-key" });

await expect(
client.listen.v1.createConnection({ model: "nova-3", keywords: "deepgram" }),
).rejects.toBeInstanceOf(BadRequestError);
});

it("the rejection mirrors the REST error: DeepgramError, status 400, points at keyterm", async () => {
const client = new DeepgramClient({ apiKey: "test-key" });

const err = await client.listen.v1.connect({ model: "nova-3", keywords: "deepgram" }).catch((e) => e);

expect(err).toBeInstanceOf(BadRequestError);
expect(err).toBeInstanceOf(DeepgramError);
expect(err.statusCode).toBe(400);
expect(err.body).toMatchObject({ err_code: "INVALID_QUERY_PARAMETER" });
// The body is rendered into the message by DeepgramError, so the fix is discoverable
// from `error.message` alone.
expect(String(err.message)).toContain("keyterm");
});
});

describe("isUnsupportedNova3Keywords predicate", () => {
it("is true for the nova-3 family with non-empty keywords", () => {
expect(isUnsupportedNova3Keywords("nova-3", "deepgram")).toBe(true);
expect(isUnsupportedNova3Keywords("nova-3-general", ["alpha", "beta"])).toBe(true);
expect(isUnsupportedNova3Keywords("nova-3-medical", "term")).toBe(true);
});

it("is false when keywords is absent or empty (nothing to reject)", () => {
expect(isUnsupportedNova3Keywords("nova-3", undefined)).toBe(false);
expect(isUnsupportedNova3Keywords("nova-3", null)).toBe(false);
expect(isUnsupportedNova3Keywords("nova-3", "")).toBe(false);
expect(isUnsupportedNova3Keywords("nova-3", [])).toBe(false);
});

it("is false for non-nova-3 models — keywords is valid there", () => {
expect(isUnsupportedNova3Keywords("nova-2", "deepgram")).toBe(false);
expect(isUnsupportedNova3Keywords("nova", "deepgram")).toBe(false);
expect(isUnsupportedNova3Keywords("enhanced", "deepgram")).toBe(false);
});

it("is false when model is missing or not a string", () => {
expect(isUnsupportedNova3Keywords(undefined, "deepgram")).toBe(false);
expect(isUnsupportedNova3Keywords(123, "deepgram")).toBe(false);
});
});
});