diff --git a/CLAUDE.md b/CLAUDE.md
index ce036212..c40595cf 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,10 +61,11 @@ aai.settings.api_key = "your-key"
- `aai.TTSError` — Raised on any Text-to-Speech refusal: `.status_code`, `.error_code` (stable API code; `biometric_terms_not_accepted` 403 until the account accepts the biometric data terms in the dashboard, `auth_unavailable` 503 retryable; on a session these arrive as close 3006 and 1011, with `status_code` None, the close code in `.body["close_code"]` and `.retryable` from the API's Error frame), `.param`, `.request_id`, `.retryable`, `.retry_after`, `.errors`, `.body` (the whole envelope)
- `assemblyai.streaming.v3.RealTimeTranscriber` — Real-time streaming with event-based API (threaded)
- `assemblyai.streaming.v3.AsyncRealTimeTranscriber` — Asyncio-native counterpart; same options/events
-- `aai.LLMGateway` — LLM Gateway client. Resources: `models.list()`, `chat.completions.create()` (incl. `stream=True`), `understanding.create()`/`.validate()`
+- `aai.LLMGateway` — LLM Gateway client. Resources: `models.list()`, `chat.completions.create()` (incl. `stream=True`), `understanding.create()`/`.validate()`, `decisions.create()`
- `aai.AsyncLLMGateway` — Asyncio counterpart of `LLMGateway`. Same resources, every API call a coroutine; owns an HTTP pool (`async with` or `await aclose()`)
- `assemblyai.llm_gateway.v1` — Canonical module for both gateways, matching the gateway's `/v1` API. The top-level `aai.*` names re-export it
- `aai.LLMGatewayMessageParam` — `TypedDict` for a chat message; pass plain dicts (`{"role": "user", "content": "..."}`)
+- `aai.LLMGatewayDecisionQuestionParam` — `TypedDict` for a decisions question; pass plain dicts (`{"type": "predicate", "name": "...", "instructions": "..."}`)
## Common patterns
@@ -574,6 +575,28 @@ gateway.understanding.validate(request={"translation": {"target_languages": ["es
when the request is valid and raises `aai.LLMGatewayError` with `.errors` populated otherwise;
it costs the same auth/balance as a real call, so it is not a free dry run.
+**Decisions**: answers `predicate`, `choice`, and `score` questions about an input (a string or a
+list of input item dicts) in one request. Questions are plain dicts (`aai.LLMGatewayDecisionQuestionParam`);
+each answer carries its question's `name`. `prompt_cache_key`, `prompt_cache_options`, `provider`,
+`fallbacks`, `fallback_config`, and `model_region` are named, and any other keyword argument is forwarded
+into the body. Only some models serve decisions, and `models.list()` does not show decisions-only models.
+```python
+result = gateway.decisions.create(
+ model="gpt-6-luna",
+ input="Agent: Is there 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"}]},
+ {"type": "score", "name": "politeness", "instructions": "How polite was the agent?",
+ "levels": [{"label": "rude", "description": "Dismissive"}, {"label": "polite", "description": "Courteous"}]},
+ ],
+)
+for answer in result.answers:
+ print(answer.name, answer.probability, answer.choice, answer.score)
+```
+`probabilities[].value` is the choice value (str) for a choice question and the level index (int) for a score question.
+
**Errors**: every failure raises `aai.LLMGatewayError` (an `aai.AssemblyAIError`) with
`.status_code`, `.request_id`, and `.errors` (validation messages, when the server sent any).
For a stream, only a failure before the first byte raises — a mid-stream failure truncates the
diff --git a/README.md b/README.md
index 6e2a8859..0d419b12 100644
--- a/README.md
+++ b/README.md
@@ -1558,7 +1558,7 @@ for result in transcript.auto_highlights.results:
### **LLM Gateway Examples**
-`aai.LLMGateway` is a client for AssemblyAI's LLM Gateway — model listing, OpenAI-shaped chat completions routed to OpenAI/Claude/Gemini/Bedrock models, and speech understanding over an existing transcript. `aai.AsyncLLMGateway` is the asyncio counterpart, with the same resources and every API call a coroutine.
+`aai.LLMGateway` is a client for AssemblyAI's LLM Gateway — model listing, OpenAI-shaped chat completions routed to OpenAI/Claude/Gemini/Bedrock models, speech understanding over an existing transcript, and decisions (predicate, choice, and score questions about an input). `aai.AsyncLLMGateway` is the asyncio counterpart, with the same resources and every API call a coroutine.
[Read more about the LLM Gateway.](https://www.assemblyai.com/docs/llm-gateway/quickstart)
@@ -1689,6 +1689,52 @@ except aai.LLMGatewayError as error:
+
+ Ask decisions questions about an input
+
+`decisions.create()` answers `predicate`, `choice`, and `score` questions about an input in one request. Each answer carries its question's `name`.
+
+```python
+import assemblyai as aai
+
+aai.settings.api_key = ""
+
+gateway = aai.LLMGateway()
+
+result = gateway.decisions.create(
+ model="gpt-6-luna",
+ input="Agent: Is there 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"},
+ ],
+ },
+ {
+ "type": "score",
+ "name": "politeness",
+ "instructions": "How polite was the agent?",
+ "levels": [
+ {"label": "rude", "description": "Dismissive"},
+ {"label": "polite", "description": "Courteous"},
+ ],
+ },
+ ],
+)
+
+for answer in result.answers:
+ print(answer.name, answer.probability, answer.choice, answer.score)
+```
+
+`input` can also be a list of input item dicts. Only some models serve decisions; an unsupported model raises `aai.LLMGatewayError`.
+
+
+
Use it from asyncio (`AsyncLLMGateway`)
diff --git a/assemblyai/__init__.py b/assemblyai/__init__.py
index a7653532..e71a26d8 100644
--- a/assemblyai/__init__.py
+++ b/assemblyai/__init__.py
@@ -18,6 +18,9 @@
LLMGatewayChunkChoice,
LLMGatewayChunkDelta,
LLMGatewayCompletionChunk,
+ LLMGatewayDecisionAnswer,
+ LLMGatewayDecisionQuestionParam,
+ LLMGatewayDecisionResponse,
LLMGatewayMessageParam,
LLMGatewayModel,
LLMGatewayModelList,
@@ -187,6 +190,9 @@ def __getattr__(name: str):
"LLMGatewayChunkChoice",
"LLMGatewayChunkDelta",
"LLMGatewayCompletionChunk",
+ "LLMGatewayDecisionAnswer",
+ "LLMGatewayDecisionQuestionParam",
+ "LLMGatewayDecisionResponse",
"LLMGatewayError",
"LLMGatewayMessageParam",
"LLMGatewayModel",
diff --git a/assemblyai/llm_gateway/v1/__init__.py b/assemblyai/llm_gateway/v1/__init__.py
index ef27485e..cd01533c 100644
--- a/assemblyai/llm_gateway/v1/__init__.py
+++ b/assemblyai/llm_gateway/v1/__init__.py
@@ -16,6 +16,8 @@
LLMGatewayChunkChoice,
LLMGatewayChunkDelta,
LLMGatewayCompletionChunk,
+ LLMGatewayDecisionAnswer,
+ LLMGatewayDecisionResponse,
LLMGatewayModel,
LLMGatewayModelList,
LLMGatewayResponseMessage,
@@ -23,7 +25,7 @@
LLMGatewayUnderstandingResponse,
LLMGatewayUsage,
)
-from .params import LLMGatewayMessageParam
+from .params import LLMGatewayDecisionQuestionParam, LLMGatewayMessageParam
__all__ = [
"AsyncLLMGateway",
@@ -33,6 +35,9 @@
"LLMGatewayChunkChoice",
"LLMGatewayChunkDelta",
"LLMGatewayCompletionChunk",
+ "LLMGatewayDecisionAnswer",
+ "LLMGatewayDecisionQuestionParam",
+ "LLMGatewayDecisionResponse",
"LLMGatewayError",
"LLMGatewayMessageParam",
"LLMGatewayModel",
diff --git a/assemblyai/llm_gateway/v1/_base.py b/assemblyai/llm_gateway/v1/_base.py
index 8775b431..b23b44d0 100644
--- a/assemblyai/llm_gateway/v1/_base.py
+++ b/assemblyai/llm_gateway/v1/_base.py
@@ -5,9 +5,9 @@
calls themselves.
"""
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Union
-from .params import LLMGatewayMessageParam
+from .params import LLMGatewayDecisionQuestionParam, LLMGatewayMessageParam
class _BaseResource:
@@ -73,3 +73,19 @@ def validate_body(
body["transcript_id"] = transcript_id
return body
+
+
+def decision_body(
+ *,
+ model: str,
+ input: Union[str, List[Dict[str, Any]]],
+ questions: List[LLMGatewayDecisionQuestionParam],
+ extra: Dict[str, Any],
+) -> Dict[str, Any]:
+ """Builds the request body of `POST /v1/decisions`."""
+ return {
+ "model": model,
+ "input": input,
+ "questions": questions,
+ **extra,
+ }
diff --git a/assemblyai/llm_gateway/v1/api.py b/assemblyai/llm_gateway/v1/api.py
index db5f03aa..44886641 100644
--- a/assemblyai/llm_gateway/v1/api.py
+++ b/assemblyai/llm_gateway/v1/api.py
@@ -10,6 +10,7 @@
ENDPOINT_CHAT_COMPLETIONS = "/v1/chat/completions"
ENDPOINT_UNDERSTANDING = "/v1/understanding"
ENDPOINT_UNDERSTANDING_VALIDATE = "/v1/understanding/validate"
+ENDPOINT_DECISIONS = "/v1/decisions"
def _error_from_response(response: httpx.Response) -> types.LLMGatewayError:
@@ -186,3 +187,27 @@ def validate_understanding(
if response.status_code != httpx.codes.OK:
raise _error_from_response(response)
+
+
+def create_decision(
+ client: httpx.Client,
+ *,
+ base_url: str,
+ timeout: float,
+ body: Dict[str, Any],
+) -> models.LLMGatewayDecisionResponse:
+ """
+ Answers a set of decision questions about an input.
+
+ Raises: `LLMGatewayError` on any non-200 response.
+ """
+ response = client.post(
+ base_url.rstrip("/") + ENDPOINT_DECISIONS,
+ json=body,
+ timeout=timeout,
+ )
+
+ if response.status_code != httpx.codes.OK:
+ raise _error_from_response(response)
+
+ return models.LLMGatewayDecisionResponse.parse_obj(response.json())
diff --git a/assemblyai/llm_gateway/v1/async_api.py b/assemblyai/llm_gateway/v1/async_api.py
index 3088ccb2..c9b16252 100644
--- a/assemblyai/llm_gateway/v1/async_api.py
+++ b/assemblyai/llm_gateway/v1/async_api.py
@@ -12,6 +12,7 @@
from . import models
from .api import (
ENDPOINT_CHAT_COMPLETIONS,
+ ENDPOINT_DECISIONS,
ENDPOINT_MODELS,
ENDPOINT_UNDERSTANDING,
ENDPOINT_UNDERSTANDING_VALIDATE,
@@ -152,3 +153,27 @@ async def validate_understanding(
if response.status_code != httpx.codes.OK:
raise _error_from_response(response)
+
+
+async def create_decision(
+ client: httpx.AsyncClient,
+ *,
+ base_url: str,
+ timeout: float,
+ body: Dict[str, Any],
+) -> models.LLMGatewayDecisionResponse:
+ """
+ Answers a set of decision questions about an input.
+
+ Raises: `LLMGatewayError` on any non-200 response.
+ """
+ response = await client.post(
+ base_url.rstrip("/") + ENDPOINT_DECISIONS,
+ json=body,
+ timeout=timeout,
+ )
+
+ if response.status_code != httpx.codes.OK:
+ raise _error_from_response(response)
+
+ return models.LLMGatewayDecisionResponse.parse_obj(response.json())
diff --git a/assemblyai/llm_gateway/v1/async_client.py b/assemblyai/llm_gateway/v1/async_client.py
index 92c41ac5..273b5d5f 100644
--- a/assemblyai/llm_gateway/v1/async_client.py
+++ b/assemblyai/llm_gateway/v1/async_client.py
@@ -17,7 +17,7 @@
from ... import async_client as _async_client
from . import _base, async_api, models
-from .params import LLMGatewayMessageParam
+from .params import LLMGatewayDecisionQuestionParam, LLMGatewayMessageParam
class AsyncModelsResource(_base._BaseResource):
@@ -220,6 +220,58 @@ async def validate(
)
+class AsyncDecisionsResource(_base._BaseResource):
+ """The `decisions` resource of `AsyncLLMGateway`."""
+
+ # Named keyword annotations below are for IDE/mypy discoverability only. They add no
+ # runtime validation: unnamed fields still flow through **kwargs untouched, and the
+ # gateway remains the sole source of truth for what a request accepts.
+ async def create(
+ self,
+ *,
+ model: str,
+ input: Union[str, List[Dict[str, Any]]],
+ questions: List[LLMGatewayDecisionQuestionParam],
+ prompt_cache_key: Optional[str] = None,
+ prompt_cache_options: Optional[Dict[str, Any]] = None,
+ provider: Optional[str] = None,
+ fallbacks: Optional[List[Dict[str, Any]]] = None,
+ fallback_config: Optional[Dict[str, Any]] = None,
+ model_region: Optional[str] = None,
+ **kwargs: Any,
+ ) -> models.LLMGatewayDecisionResponse:
+ """
+ Answers a set of predicate, choice, or score questions about an input.
+
+ `input` is a string or a list of input item dicts. `questions` are
+ plain dicts (see `LLMGatewayDecisionQuestionParam`), e.g.
+ `[{"type": "choice", "name": "sentiment", "instructions": "...",
+ "choices": [{"value": "positive", "description": "..."}, ...]}]`.
+ Each answer in the response carries its question's `name`. Extra
+ keyword arguments are forwarded directly into the request body.
+
+ Raises: `LLMGatewayError` if the request fails.
+ """
+ named = {
+ "prompt_cache_key": prompt_cache_key,
+ "prompt_cache_options": prompt_cache_options,
+ "provider": provider,
+ "fallbacks": fallbacks,
+ "fallback_config": fallback_config,
+ "model_region": model_region,
+ }
+ extra = {k: v for k, v in named.items() if v is not None}
+ extra.update(kwargs)
+
+ return await async_api.create_decision(
+ self._gateway.client.http_client,
+ body=_base.decision_body(
+ model=model, input=input, questions=questions, extra=extra
+ ),
+ **self._http_options,
+ )
+
+
class AsyncLLMGateway:
"""
The asyncio counterpart of `LLMGateway`.
@@ -275,6 +327,7 @@ def __init__(
self.models = AsyncModelsResource(self)
self.chat = AsyncChatResource(self)
self.understanding = AsyncUnderstandingResource(self)
+ self.decisions = AsyncDecisionsResource(self)
@property
def client(self) -> _async_client.AsyncClient:
diff --git a/assemblyai/llm_gateway/v1/client.py b/assemblyai/llm_gateway/v1/client.py
index f5d8f5bf..07f48fda 100644
--- a/assemblyai/llm_gateway/v1/client.py
+++ b/assemblyai/llm_gateway/v1/client.py
@@ -2,7 +2,7 @@
from ... import client as _client
from . import _base, api, models
-from .params import LLMGatewayMessageParam
+from .params import LLMGatewayDecisionQuestionParam, LLMGatewayMessageParam
class ModelsResource(_base._BaseResource):
@@ -204,6 +204,58 @@ def validate(
)
+class DecisionsResource(_base._BaseResource):
+ """The `decisions` resource of `LLMGateway`."""
+
+ # Named keyword annotations below are for IDE/mypy discoverability only. They add no
+ # runtime validation: unnamed fields still flow through **kwargs untouched, and the
+ # gateway remains the sole source of truth for what a request accepts.
+ def create(
+ self,
+ *,
+ model: str,
+ input: Union[str, List[Dict[str, Any]]],
+ questions: List[LLMGatewayDecisionQuestionParam],
+ prompt_cache_key: Optional[str] = None,
+ prompt_cache_options: Optional[Dict[str, Any]] = None,
+ provider: Optional[str] = None,
+ fallbacks: Optional[List[Dict[str, Any]]] = None,
+ fallback_config: Optional[Dict[str, Any]] = None,
+ model_region: Optional[str] = None,
+ **kwargs: Any,
+ ) -> models.LLMGatewayDecisionResponse:
+ """
+ Answers a set of predicate, choice, or score questions about an input.
+
+ `input` is a string or a list of input item dicts. `questions` are
+ plain dicts (see `LLMGatewayDecisionQuestionParam`), e.g.
+ `[{"type": "choice", "name": "sentiment", "instructions": "...",
+ "choices": [{"value": "positive", "description": "..."}, ...]}]`.
+ Each answer in the response carries its question's `name`. Extra
+ keyword arguments are forwarded directly into the request body.
+
+ Raises: `LLMGatewayError` if the request fails.
+ """
+ named = {
+ "prompt_cache_key": prompt_cache_key,
+ "prompt_cache_options": prompt_cache_options,
+ "provider": provider,
+ "fallbacks": fallbacks,
+ "fallback_config": fallback_config,
+ "model_region": model_region,
+ }
+ extra = {k: v for k, v in named.items() if v is not None}
+ extra.update(kwargs)
+
+ return api.create_decision(
+ self._gateway.client.http_client,
+ body=_base.decision_body(
+ model=model, input=input, questions=questions, extra=extra
+ ),
+ **self._http_options,
+ )
+
+
class LLMGateway:
"""
Client for AssemblyAI's LLM Gateway.
@@ -247,6 +299,7 @@ def __init__(
self.models = ModelsResource(self)
self.chat = ChatResource(self)
self.understanding = UnderstandingResource(self)
+ self.decisions = DecisionsResource(self)
@property
def client(self) -> _client.Client:
diff --git a/assemblyai/llm_gateway/v1/models.py b/assemblyai/llm_gateway/v1/models.py
index 8f1a3e60..b22c46eb 100644
--- a/assemblyai/llm_gateway/v1/models.py
+++ b/assemblyai/llm_gateway/v1/models.py
@@ -291,3 +291,65 @@ def set_collection_default(cls, v):
@validator("speech_understanding", pre=True)
def set_collection_default(cls, v):
return {} if v is None else v
+
+
+class LLMGatewayDecisionProbability(_LLMGatewayResponseModel):
+ value: Any = None
+ "The choice value (str) for a choice question, or the level index (int) for a score question"
+
+ label: Optional[str] = None
+ probability: float
+
+
+class LLMGatewayDecisionAnswer(_LLMGatewayResponseModel):
+ """
+ The answer to one decisions question, matched to it by `name`.
+
+ Which fields are set depends on the question `type`.
+ """
+
+ type: Optional[str] = None
+ name: Optional[str] = None
+ probability: Optional[float] = None
+ choice: Optional[str] = None
+ score: Optional[float] = None
+ probabilities: Optional[List[LLMGatewayDecisionProbability]] = None
+ confidence: Optional[float] = None
+
+
+class LLMGatewayDecisionInputTokensDetails(_LLMGatewayResponseModel):
+ cached_tokens: int
+ cache_write_tokens: int
+
+
+class LLMGatewayDecisionOutputTokensDetails(_LLMGatewayResponseModel):
+ reasoning_tokens: int
+
+
+class LLMGatewayDecisionUsage(_LLMGatewayResponseModel):
+ input_tokens: int
+ input_tokens_details: Optional[LLMGatewayDecisionInputTokensDetails] = None
+ output_tokens: int
+ output_tokens_details: Optional[LLMGatewayDecisionOutputTokensDetails] = None
+ total_tokens: int
+
+
+class LLMGatewayDecisionResponse(_LLMGatewayResponseModel):
+ """The response of `POST /v1/decisions`."""
+
+ request_id: str
+ model: str
+ answers: List[LLMGatewayDecisionAnswer] = Field(default_factory=list)
+ usage: LLMGatewayDecisionUsage
+
+ if pydantic_v2:
+
+ @field_validator("answers", mode="before")
+ def set_collection_default(cls, v):
+ return [] if v is None else v
+
+ else:
+
+ @validator("answers", pre=True)
+ def set_collection_default(cls, v):
+ return [] if v is None else v
diff --git a/assemblyai/llm_gateway/v1/params.py b/assemblyai/llm_gateway/v1/params.py
index 8f67d91a..2722c6b1 100644
--- a/assemblyai/llm_gateway/v1/params.py
+++ b/assemblyai/llm_gateway/v1/params.py
@@ -15,3 +15,20 @@ class LLMGatewayMessageParam(TypedDict, total=False):
name: str
thinking: str
cache_control: Dict[str, Any]
+
+
+class LLMGatewayDecisionQuestionParam(TypedDict, total=False):
+ """A decisions question. Pass as a plain dict: `{"type": "predicate", "name": "...", "instructions": "..."}`."""
+
+ type: Required[str]
+ '"predicate", "choice" (requires `choices`), or "score" (requires `levels`)'
+
+ name: Required[str]
+ "Unique within the request; echoed on the matching answer"
+
+ instructions: Required[Union[str, List[Dict[str, Any]]]]
+ choices: List[Dict[str, str]]
+ '`[{"value": ..., "description": ...}]`, for "choice" questions'
+
+ levels: List[Dict[str, str]]
+ '`[{"label": ..., "description": ...}]`, for "score" questions'
diff --git a/tests/unit/test_llm_gateway.py b/tests/unit/test_llm_gateway.py
index 93961923..3df9e545 100644
--- a/tests/unit/test_llm_gateway.py
+++ b/tests/unit/test_llm_gateway.py
@@ -13,6 +13,7 @@
UNDERSTANDING_VALIDATE_URL = (
f"{aai.settings.llm_gateway_base_url}/v1/understanding/validate"
)
+DECISIONS_URL = f"{aai.settings.llm_gateway_base_url}/v1/decisions"
_OK_RESPONSE = {
"data": [
@@ -757,3 +758,236 @@ def test_stream_chunk_exposes_tool_call_delta(httpx_mock: HTTPXMock):
assert tool_call.function.name == "get_weather"
# arguments stream in fragments; the SDK doesn't reassemble them
assert tool_call.function.arguments == '{"ci'
+
+
+_DECISION_QUESTIONS = [
+ {
+ "type": "predicate",
+ "name": "escalated",
+ "instructions": "Was the call escalated?",
+ },
+ {
+ "type": "choice",
+ "name": "sentiment",
+ "instructions": "Overall caller sentiment",
+ "choices": [
+ {"value": "positive", "description": "Caller was satisfied"},
+ {"value": "negative", "description": "Caller was unhappy"},
+ ],
+ },
+ {
+ "type": "score",
+ "name": "politeness",
+ "instructions": "How polite was the agent?",
+ "levels": [
+ {"label": "rude", "description": "Agent was rude"},
+ {"label": "polite", "description": "Agent was polite"},
+ ],
+ },
+]
+
+_DECISION_RESPONSE = {
+ "request_id": "req-decision-1",
+ "model": "gpt-6-luna",
+ "answers": [
+ {"type": "predicate", "name": "escalated", "probability": 0.12},
+ {
+ "type": "choice",
+ "name": "sentiment",
+ "choice": "positive",
+ "confidence": 0.9,
+ "probabilities": [
+ {"value": "positive", "probability": 0.9},
+ {"value": "negative", "probability": 0.1},
+ ],
+ },
+ {
+ "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": 100, "cache_write_tokens": 0},
+ "output_tokens": 3,
+ "output_tokens_details": {"reasoning_tokens": 0},
+ "total_tokens": 123,
+ },
+}
+
+
+def test_decisions_create_parses_response(httpx_mock: HTTPXMock):
+ # Given a mocked decisions endpoint
+ httpx_mock.add_response(
+ url=DECISIONS_URL,
+ method="POST",
+ status_code=httpx.codes.OK,
+ json=_DECISION_RESPONSE,
+ )
+
+ # When asking predicate, choice, and score questions
+ result = aai.LLMGateway().decisions.create(
+ model="gpt-6-luna",
+ input="Agent: Hello! Caller: Thanks, that fixed it.",
+ questions=_DECISION_QUESTIONS,
+ )
+
+ # Then the response is parsed into an LLMGatewayDecisionResponse
+ assert isinstance(result, aai.LLMGatewayDecisionResponse)
+ assert result.request_id == "req-decision-1"
+ assert result.model == "gpt-6-luna"
+ predicate, choice, score = result.answers
+ assert isinstance(predicate, aai.LLMGatewayDecisionAnswer)
+ assert predicate.probability == 0.12
+ assert choice.choice == "positive"
+ assert choice.confidence == 0.9
+ assert score.score == 0.8
+ assert result.usage.input_tokens_details.cached_tokens == 100
+ assert result.usage.total_tokens == 123
+
+
+def test_decisions_probability_value_keeps_wire_type(httpx_mock: HTTPXMock):
+ # Given a response with string choice values and integer level indexes
+ httpx_mock.add_response(
+ url=DECISIONS_URL,
+ method="POST",
+ status_code=httpx.codes.OK,
+ json=_DECISION_RESPONSE,
+ )
+
+ # When creating a decision
+ result = aai.LLMGateway().decisions.create(
+ model="gpt-6-luna", input="...", questions=_DECISION_QUESTIONS
+ )
+
+ # Then each probability value keeps the type it was sent with
+ assert result.answers[1].probabilities[0].value == "positive"
+ assert result.answers[2].probabilities[1].value == 1
+ assert isinstance(result.answers[2].probabilities[1].value, int)
+ assert result.answers[2].probabilities[1].label == "polite"
+
+
+def test_decisions_create_sends_body_and_extension_kwargs(httpx_mock: HTTPXMock):
+ # Given a mocked decisions endpoint
+ httpx_mock.add_response(
+ url=DECISIONS_URL,
+ method="POST",
+ status_code=httpx.codes.OK,
+ json=_DECISION_RESPONSE,
+ )
+
+ # When passing named options and a field the SDK doesn't name
+ aai.LLMGateway().decisions.create(
+ model="gpt-6-luna",
+ input=[{"role": "user", "content": "..."}],
+ questions=_DECISION_QUESTIONS,
+ prompt_cache_key="call-123",
+ fallbacks=[{"model": "gpt-5.4"}],
+ brand_new_param=True,
+ )
+
+ # Then every field reaches the wire unrenamed
+ import json as _json
+
+ payload = _json.loads(httpx_mock.get_requests()[0].read())
+ assert payload == {
+ "model": "gpt-6-luna",
+ "input": [{"role": "user", "content": "..."}],
+ "questions": _DECISION_QUESTIONS,
+ "prompt_cache_key": "call-123",
+ "fallbacks": [{"model": "gpt-5.4"}],
+ "brand_new_param": True,
+ }
+
+
+def test_decisions_create_omits_unset_named_options(httpx_mock: HTTPXMock):
+ # Given a mocked decisions endpoint
+ httpx_mock.add_response(
+ url=DECISIONS_URL,
+ method="POST",
+ status_code=httpx.codes.OK,
+ json=_DECISION_RESPONSE,
+ )
+
+ # When passing only the required fields
+ aai.LLMGateway().decisions.create(
+ model="gpt-6-luna", input="...", questions=_DECISION_QUESTIONS
+ )
+
+ # Then no optional field is sent as null
+ import json as _json
+
+ payload = _json.loads(httpx_mock.get_requests()[0].read())
+ assert set(payload) == {"model", "input", "questions"}
+
+
+def test_decisions_tolerates_null_answers(httpx_mock: HTTPXMock):
+ # Given a response whose answers slice was nil on the Go side
+ httpx_mock.add_response(
+ url=DECISIONS_URL,
+ method="POST",
+ status_code=httpx.codes.OK,
+ json=dict(_DECISION_RESPONSE, answers=None),
+ )
+
+ # When creating a decision
+ result = aai.LLMGateway().decisions.create(
+ model="gpt-6-luna", input="...", questions=_DECISION_QUESTIONS
+ )
+
+ # Then answers parses as an empty list
+ assert result.answers == []
+
+
+def test_decisions_preserves_unknown_server_fields(httpx_mock: HTTPXMock):
+ # Given a decision response carrying fields this SDK version doesn't model
+ response = dict(_DECISION_RESPONSE, brand_new_field={"nested": 1})
+ response["answers"] = [dict(_DECISION_RESPONSE["answers"][0], rationale="...")]
+ httpx_mock.add_response(
+ url=DECISIONS_URL,
+ method="POST",
+ status_code=httpx.codes.OK,
+ json=response,
+ )
+
+ # When creating a decision
+ result = aai.LLMGateway().decisions.create(
+ model="gpt-6-luna", input="...", questions=_DECISION_QUESTIONS
+ )
+
+ # Then the unknown fields are still reachable, not dropped
+ assert result.brand_new_field == {"nested": 1}
+ assert result.answers[0].rationale == "..."
+
+
+def test_decisions_create_raises_with_errors_list(httpx_mock: HTTPXMock):
+ # Given a mocked decisions endpoint returning a 400 with validation messages
+ httpx_mock.add_response(
+ url=DECISIONS_URL,
+ method="POST",
+ status_code=httpx.codes.BAD_REQUEST,
+ json={
+ "message": "invalid request body",
+ "code": 400,
+ "request_id": "req-decision-2",
+ "metadata": {"errors": ["questions[0].name: is required"]},
+ },
+ )
+
+ # When sending an invalid request
+ with pytest.raises(aai.LLMGatewayError) as exc_info:
+ aai.LLMGateway().decisions.create(
+ model="gpt-6-luna",
+ input="...",
+ questions=[{"type": "predicate", "name": "", "instructions": "?"}],
+ )
+
+ # Then the error carries the status, request id, and validation messages
+ assert exc_info.value.status_code == 400
+ assert exc_info.value.request_id == "req-decision-2"
+ assert exc_info.value.errors == ["questions[0].name: is required"]
diff --git a/tests/unit/test_llm_gateway_async.py b/tests/unit/test_llm_gateway_async.py
index 3cabd09a..cc6867bd 100644
--- a/tests/unit/test_llm_gateway_async.py
+++ b/tests/unit/test_llm_gateway_async.py
@@ -16,6 +16,7 @@
UNDERSTANDING_VALIDATE_URL = (
f"{aai.settings.llm_gateway_base_url}/v1/understanding/validate"
)
+DECISIONS_URL = f"{aai.settings.llm_gateway_base_url}/v1/decisions"
_OK_RESPONSE = {
"data": [
@@ -309,3 +310,107 @@ async def test_aclose_closes_owned_client_only():
pass
# Then a caller-supplied client is left open for the caller to close
assert not shared_client.http_client.is_closed
+
+
+_DECISION_QUESTIONS = [
+ {
+ "type": "predicate",
+ "name": "escalated",
+ "instructions": "Was the call escalated?",
+ },
+ {
+ "type": "score",
+ "name": "politeness",
+ "instructions": "How polite was the agent?",
+ "levels": [
+ {"label": "rude", "description": "Agent was rude"},
+ {"label": "polite", "description": "Agent was polite"},
+ ],
+ },
+]
+
+_DECISION_RESPONSE = {
+ "request_id": "req-decision-1",
+ "model": "gpt-6-luna",
+ "answers": [
+ {"type": "predicate", "name": "escalated", "probability": 0.12},
+ {
+ "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": 100, "cache_write_tokens": 0},
+ "output_tokens": 3,
+ "output_tokens_details": {"reasoning_tokens": 0},
+ "total_tokens": 123,
+ },
+}
+
+
+async def test_decisions_create_parses_response(httpx_mock: HTTPXMock):
+ # Given a mocked decisions endpoint
+ httpx_mock.add_response(
+ url=DECISIONS_URL,
+ method="POST",
+ status_code=httpx.codes.OK,
+ json=_DECISION_RESPONSE,
+ )
+
+ # When asking predicate and score questions
+ async with aai.AsyncLLMGateway() as gateway:
+ result = await gateway.decisions.create(
+ model="gpt-6-luna",
+ input="Agent: Hello! Caller: Thanks, that fixed it.",
+ questions=_DECISION_QUESTIONS,
+ model_region="global",
+ )
+
+ # Then the response is parsed and the request body carries every field
+ assert isinstance(result, aai.LLMGatewayDecisionResponse)
+ assert result.answers[0].probability == 0.12
+ assert result.answers[1].probabilities[1].value == 1
+ assert result.usage.total_tokens == 123
+ payload = json.loads(httpx_mock.get_requests()[0].read())
+ assert payload == {
+ "model": "gpt-6-luna",
+ "input": "Agent: Hello! Caller: Thanks, that fixed it.",
+ "questions": _DECISION_QUESTIONS,
+ "model_region": "global",
+ }
+
+
+async def test_decisions_create_raises_with_errors_list(httpx_mock: HTTPXMock):
+ # Given a mocked decisions endpoint returning a 400 with validation messages
+ httpx_mock.add_response(
+ url=DECISIONS_URL,
+ method="POST",
+ status_code=httpx.codes.BAD_REQUEST,
+ json={
+ "message": "invalid request body",
+ "code": 400,
+ "request_id": "req-decision-2",
+ "metadata": {
+ "errors": ['model: "gpt-4o" is not supported on /v1/decisions']
+ },
+ },
+ )
+
+ # When sending a request for a model that doesn't serve decisions
+ async with aai.AsyncLLMGateway() as gateway:
+ with pytest.raises(aai.LLMGatewayError) as exc_info:
+ await gateway.decisions.create(
+ model="gpt-4o", input="...", questions=_DECISION_QUESTIONS
+ )
+
+ # Then the error carries the status and validation messages
+ assert exc_info.value.status_code == 400
+ assert exc_info.value.errors == [
+ 'model: "gpt-4o" is not supported on /v1/decisions'
+ ]