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' + ]