From 96b794d14292a842a657a8701268b5ef27eedcf2 Mon Sep 17 00:00:00 2001 From: Daniel Morrissey Date: Fri, 6 Nov 2026 15:41:11 -0700 Subject: [PATCH] feat(llmgw): add llmGateway.decisions() for the gateway's /v1/decisions - New `decisions(request)` on LlmGatewayService with Decision* request/response types; untyped request fields still pass through by name. - Unit and integration tests, CLAUDE.md docs, CHANGELOG entry, version 4.43.0. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ CLAUDE.md | 33 +++++++++++- package.json | 2 +- src/services/llm-gateway/service.ts | 19 ++++++- src/types/llm-gateway/index.ts | 75 +++++++++++++++++++++++++++ tests/integration/llm-gateway.test.ts | 31 +++++++++++ tests/unit/llm-gateway.test.ts | 55 ++++++++++++++++++++ 7 files changed, 214 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afb1bfb..9f88eac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [4.43.0] + +- Add `client.llmGateway.decisions(request)` for the LLM Gateway's `/v1/decisions` endpoint: answers `predicate`, `choice`, and `score` questions about an input in one request. New types `DecisionRequest`, `DecisionQuestion`, `DecisionResponse`, `DecisionAnswer`, `DecisionProbability`, and `DecisionUsage`; untyped request fields are still sent by name + ## [4.42.0] - Add Text-to-Speech: `client.tts.synthesize(request)` returns one text's complete audio (`TextToSpeechResponse`: `audio` plus the response headers as `requestId`, `region`, `model`, `voiceId`, `encoding`, `sampleRate`, `billedCharacters`, `audioDurationMs`, ...), `client.tts.stream(request)` returns it as the API generates it (`TextToSpeechStream`, an async iterable over a web `ReadableStream`), and `client.tts.session(params)` opens a WebSocket `TextToSpeechSession` for voice agents: `connect()`, `generate()` / `flush()` / `audio()`, `speak()`, `cancel()`, `keepAlive()`, `close()`, with `on("audio" | "flushDone" | "wordBoundaries" | "cancelled" | "termination" | "error" | "close", ...)`. Requests take the API's fields (`text`, `voice_id`, `model_id`, `language`, `sample_rate`, `encoding`, `container`, `bit_rate`); failures throw `TextToSpeechError` (`status`, `errorCode`, `param`, `requestId`, `retryable`, `retryAfter`, `errors`, `closeCode`, `body`). New client option `ttsBaseUrl` (default `https://tts.us.assemblyai.com`); `client.tts.createTemporaryToken()` mints a token for browser sessions. Text sent while `connect()` is pending is held and sent the moment the socket opens, so it need not wait for `Begin` diff --git a/CLAUDE.md b/CLAUDE.md index 7084cc3..73fef12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ const client = new AssemblyAI({ - `client.llmGateway.chatCompletions(request)` — OpenAI-compatible chat completions - `client.llmGateway.listModels()` — List models available on the LLM Gateway - `client.llmGateway.understanding(request)` / `client.llmGateway.validateUnderstanding(request)` — Run or validate a Speech Understanding request +- `client.llmGateway.decisions(request)` — Answer predicate, choice, or score questions about an input - `client.tts.synthesize({ text, voice_id? })` — Text-to-speech in one request: the complete audio (`TextToSpeechResponse.audio`, a Uint8Array) plus the response header fields - `client.tts.stream(request, { signal? })` — Text-to-speech audio as it is generated: a `TextToSpeechStream`, an async iterable of Uint8Array chunks with `info` (header fields), `body`, `bytes()` and `cancel()` - `client.tts.session({ voiceId })` — WebSocket `TextToSpeechSession` that speaks text as it is written (voice agents): `connect({ token? })` (text sent before it resolves is held until the socket opens), `speak(text)` (async iterable), `generate()`/`flush()`/`audio(flushId?)`, `cancel()`, `keepAlive()`, `close()`, `on("begin"|"audio"|"flushDone"|"wordBoundaries"|"cancelled"|"termination"|"error"|"close", fn)` @@ -403,8 +404,8 @@ upload, the transcription and the LLM pass; the service caps audio at 120 s. `si ## LLM Gateway `client.llmGateway` targets the LLM Gateway host (`llm-gateway.assemblyai.com`, override with the -`llmGatewayBaseUrl` client option). It exposes an OpenAI-compatible chat completions API and -AssemblyAI's Speech Understanding endpoints. +`llmGatewayBaseUrl` client option). It exposes an OpenAI-compatible chat completions API, +AssemblyAI's Speech Understanding endpoints, and decisions. ```typescript const completion = await client.llmGateway.chatCompletions({ @@ -427,6 +428,34 @@ const result = await client.llmGateway.understanding({ }); ``` +Decisions answers `predicate`, `choice`, and `score` questions about an input (a string or a list +of input items) in one request. Each answer carries its question's `name`; `probabilities[].value` +is the choice value (string) for a choice question and the level index (number) for a score question. +Only some models serve decisions, and `listModels()` does not show decisions-only models. + +```typescript +const { answers } = await client.llmGateway.decisions({ + model: "gpt-6-luna", + input: "Agent: Anything else? Caller: No, that fixed it, thanks!", + questions: [ + { + type: "predicate", + name: "resolved", + instructions: "Was the issue resolved?", + }, + { + type: "choice", + name: "sentiment", + instructions: "Caller sentiment at the end of the call", + choices: [ + { value: "positive", description: "Satisfied" }, + { value: "negative", description: "Unhappy" }, + ], + }, + ], +}); +``` + Failures throw `LlmGatewayError` with `.status`, `.requestId`, and `.errors` (validation detail strings, when present). diff --git a/package.json b/package.json index f4507f2..dd645b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assemblyai", - "version": "4.42.0", + "version": "4.43.0", "description": "The AssemblyAI JavaScript SDK provides an easy-to-use interface for interacting with the AssemblyAI API, which supports async and real-time transcription and text-to-speech.", "engines": { "node": ">=18" diff --git a/src/services/llm-gateway/service.ts b/src/services/llm-gateway/service.ts index 71081c7..f7f8560 100644 --- a/src/services/llm-gateway/service.ts +++ b/src/services/llm-gateway/service.ts @@ -3,6 +3,8 @@ import { BaseServiceParams, ChatCompletionRequest, ChatCompletionResponse, + DecisionRequest, + DecisionResponse, ListModelsResponse, LlmGatewaySpeechUnderstandingRequest, LlmGatewaySpeechUnderstandingResponse, @@ -10,8 +12,8 @@ import { import { LlmGatewayError } from "../../utils/errors/llm-gateway"; /** - * The LLM Gateway service: an OpenAI-compatible chat completions API plus - * AssemblyAI's Speech Understanding endpoints. + * The LLM Gateway service: an OpenAI-compatible chat completions API, + * AssemblyAI's Speech Understanding endpoints, and decisions. */ export class LlmGatewayService extends BaseService { /** @@ -84,6 +86,19 @@ export class LlmGatewayService extends BaseService { }); if (!response.ok) throw await errorFromResponse(response); } + + /** + * Answer predicate, choice, or score questions about an input. + * @throws LlmGatewayError when the request fails. + */ + async decisions(request: DecisionRequest): Promise { + const response = await this.fetchResponse("/v1/decisions", { + method: "POST", + body: JSON.stringify(request), + }); + if (!response.ok) throw await errorFromResponse(response); + return (await response.json()) as DecisionResponse; + } } /** diff --git a/src/types/llm-gateway/index.ts b/src/types/llm-gateway/index.ts index 7d8fb57..33c32b6 100644 --- a/src/types/llm-gateway/index.ts +++ b/src/types/llm-gateway/index.ts @@ -124,3 +124,78 @@ export type LlmGatewaySpeechUnderstandingRequest = { * transcript source, so it is left loosely typed. */ export type LlmGatewaySpeechUnderstandingResponse = Record; + +/** + * A question in a `/v1/decisions` request. `choices` is required for + * `choice` questions and `levels` for `score` questions. + */ +export type DecisionQuestion = { + type: "predicate" | "choice" | "score"; + /** Unique within the request; echoed on the matching answer. */ + name: string; + /** A string, or a list of `input_text` / `input_image` parts. */ + instructions: string | Record[]; + choices?: { value: string; description: string }[]; + levels?: { label: string; description: string }[]; +}; + +/** + * A request to the LLM Gateway's `/v1/decisions` endpoint. Fields not typed + * here can still be passed by name. + */ +export type DecisionRequest = { + model: string; + /** A string, or a list of OpenAI input items. */ + input: string | Record[]; + questions: DecisionQuestion[]; + prompt_cache_key?: string; + prompt_cache_options?: { mode?: string; ttl?: string }; + provider?: string; + fallbacks?: Record[]; + fallback_config?: { + depth?: number; + retry?: boolean; + circuit_breaker?: boolean; + }; + model_region?: string; + [key: string]: unknown; +}; + +export type DecisionProbability = { + /** The choice value for a `choice` question, or the level index for a `score` question. */ + value: string | number; + label?: string; + probability: number; +}; + +/** + * The answer to one question, matched to it by `name`. Which fields are set + * depends on the question `type`. + */ +export type DecisionAnswer = { + type?: string; + name?: string; + probability?: number; + choice?: string; + score?: number; + probabilities?: DecisionProbability[]; + confidence?: number; +}; + +export type DecisionUsage = { + input_tokens: number; + input_tokens_details: { cached_tokens: number; cache_write_tokens: number }; + output_tokens: number; + output_tokens_details: { reasoning_tokens: number }; + total_tokens: number; +}; + +/** + * The response from the LLM Gateway's `/v1/decisions` endpoint. + */ +export type DecisionResponse = { + request_id: string; + model: string; + answers: DecisionAnswer[]; + usage: DecisionUsage; +}; diff --git a/tests/integration/llm-gateway.test.ts b/tests/integration/llm-gateway.test.ts index af64beb..b3601a0 100644 --- a/tests/integration/llm-gateway.test.ts +++ b/tests/integration/llm-gateway.test.ts @@ -41,4 +41,35 @@ describe("llmGateway", () => { const result = await client.llmGateway.understanding(request); expect(result).toBeTruthy(); }); + + it("should answer decisions questions", async () => { + const result = await client.llmGateway.decisions({ + model: "gpt-6-luna", + input: "Agent: Anything else? Caller: No, that fixed it, thanks!", + questions: [ + { + type: "predicate", + name: "resolved", + instructions: "Was the caller's issue resolved?", + }, + { + type: "choice", + name: "sentiment", + instructions: "Caller sentiment at the end of the call", + choices: [ + { value: "positive", description: "Satisfied" }, + { value: "negative", description: "Unhappy" }, + ], + }, + ], + }); + + expect(result.request_id).toBeTruthy(); + expect(result.answers.map((a) => a.name)).toEqual([ + "resolved", + "sentiment", + ]); + expect(["positive", "negative"]).toContain(result.answers[1].choice); + expect(result.usage.total_tokens).toBeGreaterThan(0); + }); }); diff --git a/tests/unit/llm-gateway.test.ts b/tests/unit/llm-gateway.test.ts index db98e26..e2974a8 100644 --- a/tests/unit/llm-gateway.test.ts +++ b/tests/unit/llm-gateway.test.ts @@ -120,6 +120,61 @@ describe("llmGateway", () => { ).resolves.toBeUndefined(); }); + it("should run a decisions request", async () => { + const response = { + request_id: "req_dec", + model: "gpt-6-luna", + answers: [ + { type: "predicate", name: "resolved", probability: 0.9 }, + { + type: "score", + name: "politeness", + score: 0.8, + probabilities: [ + { value: 0, label: "rude", probability: 0.2 }, + { value: 1, label: "polite", probability: 0.8 }, + ], + }, + ], + usage: { + input_tokens: 120, + input_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 }, + output_tokens: 0, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 120, + }, + }; + fetchMock.doMockOnceIf( + requestMatches({ url: "/v1/decisions", method: "POST" }), + JSON.stringify(response), + ); + const request = { + model: "gpt-6-luna", + input: "Caller: thanks, that fixed it!", + questions: [ + { + type: "predicate" as const, + name: "resolved", + instructions: "Was the issue resolved?", + }, + { + type: "score" as const, + name: "politeness", + instructions: "How polite was the agent?", + levels: [ + { label: "rude", description: "Dismissive" }, + { label: "polite", description: "Courteous" }, + ], + }, + ], + prompt_cache_key: "call-123", + brand_new_param: true, + }; + const result = await assembly.llmGateway.decisions(request); + expect(result).toEqual(response); + expect(requestBody()).toEqual(request); + }); + it("should map the post-auth error envelope to an LlmGatewayError", async () => { fetchMock.mockResponseOnce( JSON.stringify({