Response models#
Introduction#
The router speaks JSON, and every endpoint emits a different shape. Before these models,
LLMRouterClient handed that JSON straight back as a raw dict, so the knowledge of each response schema lived on the
caller side: you had to remember the exact key names, guard against missing fields, and you got no compile‑time or IDE
help.
The response models move that contract into the library. Every client method returns a typed Pydantic model that mirrors
the JSON body the router emits for that endpoint, and the models are defined in one place —
data_models/response.py — next to the existing request models (TranslateModel,
Polarity3cModel, …). Together, request
- response describe the full contract of an endpoint.
Concretely, the typing is there to do three things:
- Fix the contract in one place.
response.pyis the single definition of what each endpoint returns. Change the router → change the model → type checkers and tests flag every consumer that still relied on the old shape. - Validate at the boundary. Pydantic checks the body as it comes in, so a malformed or partial response fails there
with a
ValidationErrorinstead of as aKeyErrora few frames deep in your code. - Expose static types. Because the return annotation is a class rather than
Dict[str, Any],mypyand IDEs can resolveresp.response[0].translated. The model also exports a JSON Schema viamodel_json_schema()for docs, codegen, or contract tests.
# resp is a typed model, not a dict
resp = client.translate(texts=["Hello world"], model="speakleash/Bielik-11B-v2.3-Instruct")
resp.response[0].translated # str
resp.generation_time # Optional[float]
resp.model_dump() # → plain dict, if you still need one
📦 Where the models live and how to import them#
All response models are exported from the package root, so either import style works:
# Option 1 – from the package (recommended)
from llm_router_lib.data_models import (
TranslateResponse,
Polarity3cResponse,
ConversationResponse,
ModelsListResponse,
# …see the full list in data_models/__init__.py
)
# Option 2 – from the module directly
from llm_router_lib.data_models.response import TranslateResponse
They sit alongside (but are independent of) the request models such as
TranslateModel, Polarity3cModel, etc.
🗺️ Client method → response model#
The table below maps every LLMRouterClient method to the model it now returns.
response is the endpoint‑specific payload; generation_time (seconds) is present on all generation endpoints.
| Client method | Endpoint | Returns | response payload |
|---|---|---|---|
ping() |
GET /api/ping |
PingResponse |
status: bool, body: str |
version() |
GET /api/version |
VersionResponse |
version: str |
models() |
GET /v1/models |
ModelsListResponse |
object: str, data: List[ModelInfo] |
conversation_with_model(payload) |
POST /api/conversation_with_model |
ConversationResponse |
str (assistant reply) |
extended_conversation_with_model(payload) |
POST /api/extended_conversation_with_model |
ExtendedConversationResponse |
str (assistant reply) |
polarity_3c(...) |
POST /api/polarity_3c |
Polarity3cResponse |
List[Polarity3cItem] |
translate(...) |
POST /api/translate |
TranslateResponse |
List[TranslateItem] |
simplify_text(...) |
POST /api/simplify_text |
SimplifyTextResponse |
List[str] |
generative_answer(...) |
POST /api/generative_answer |
GenerativeAnswerResponse |
str (answer) |
generate_article_from_text(...) |
POST /api/generate_article_from_text |
GenerateArticleFromTextResponse |
ArticleText |
generate_article_from_texts(...) |
POST /api/generate_article_from_texts |
GenerateArticleFromTextsResponse |
ArticleText |
create_full_article_from_texts(...) |
POST /api/create_full_article_from_texts |
CreateFullArticleFromTextsResponse |
ArticleText |
generate_questions(...) |
POST /api/generate_questions |
GenerateQuestionsResponse |
List[TextQuestions] |
generate_label(...) |
POST /api/generate_label |
GenerateLabelResponse |
str (label) |
🧱 Class hierarchy#
BaseResponse # extra keys ignored (pydantic default)
├── PingResponse
├── VersionResponse
├── ModelInfo
├── ModelsListResponse
├── Polarity3cItem
├── TranslateItem
├── TextQuestions
├── ArticleText
└── GenerationResponse # adds: generation_time: Optional[float]
├── ConversationResponse
├── ExtendedConversationResponse
├── Polarity3cResponse
├── TranslateResponse
├── SimplifyTextResponse
├── GenerativeAnswerResponse
├── GenerateLabelResponse
├── GenerateArticleFromTextResponse
├── GenerateArticleFromTextsResponse
└── CreateFullArticleFromTextsResponse
BaseResponse– the common base. It does not define payload fields; it only sets the shared model configuration (unknown keys are ignored).GenerationResponse– the base for every generative endpoint; it contributes thegeneration_timefield. Concrete subclasses declare the type ofresponse.BaseResponse/GenerationResponseare also the base for the small nested item models (Polarity3cItem,TranslateItem,TextQuestions,ArticleText,ModelInfo).
📚 Model reference#
Health / meta#
class PingResponse(BaseResponse):
status: bool = True # True on success
body: Optional[str] = None # e.g. "pong"
class VersionResponse(BaseResponse):
version: str = "" # e.g. "1.4.2"
class ModelInfo(BaseResponse):
id: str # model identifier, e.g. "google/gemma-3-12b-it"
object: Optional[str] = None
created: Optional[float] = None
owned_by: Optional[str] = None
class ModelsListResponse(BaseResponse):
object: str = "list"
data: List[ModelInfo] = []
@property
def ids(self) -> List[str]: # convenience: just the identifiers
...
models()previously returnedList[str]. It now returnsModelsListResponse; readclient.models().idsto get the previous list of names, orclient.models().datafor the full entries.
Conversation#
class ConversationResponse(GenerationResponse):
response: Optional[str] = None # the assistant's reply text
class ExtendedConversationResponse(GenerationResponse):
response: Optional[str] = None # the assistant's reply text
Per‑text (list) utilities#
class Polarity3cItem(BaseResponse):
original: str = "" # the input text
polarity: str = "" # "positive" | "negative" | "ambivalent"
class Polarity3cResponse(GenerationResponse):
response: List[Polarity3cItem] = [] # one item per input text
class TranslateItem(BaseResponse):
original: str = "" # the input text
translated: str = "" # the translation
class TranslateResponse(GenerationResponse):
response: List[TranslateItem] = [] # one item per input text
class SimplifyTextResponse(GenerationResponse):
response: List[str] = [] # the simplified texts, in input order
class TextQuestions(BaseResponse):
text: str = "" # the input text
questions: List[str] = [] # the generated questions
class GenerateQuestionsResponse(GenerationResponse):
response: List[TextQuestions] = [] # one entry per input text
Single‑output utilities#
class GenerativeAnswerResponse(GenerationResponse):
response: Optional[str] = None # the generated answer
class GenerateLabelResponse(GenerationResponse):
response: Optional[str] = None # the generated category label
Article utilities#
class ArticleText(BaseResponse):
article_text: Optional[str] = None # the generated article
class GenerateArticleFromTextResponse(GenerationResponse):
response: ArticleText = ArticleText()
class GenerateArticleFromTextsResponse(GenerationResponse):
response: ArticleText = ArticleText()
class CreateFullArticleFromTextsResponse(GenerationResponse):
response: ArticleText = ArticleText()
🎨 Design decisions#
Tolerance over strictness. Real router responses can carry extra keys (the
status/body envelope, book‑keeping fields) and sometimes omit an optional field. The models are therefore
intentionally permissive:
- Unknown keys are ignored (Pydantic's default, re‑stated in
BaseResponseviaConfigDict(extra="ignore")). - Fields have sensible defaults —
Nonefor strings,[]for lists, and an emptyArticleTextfor the article payload — so a partial response still validates. generation_timeis optional, because some deployments/versions may omit it.
This means the same model validates both the canonical server payload ({"response": …, "generation_time": …}) and a
partial/status‑wrapped body ({"status": true, "response": …}).
response is the single point of variation. Every generation endpoint returns a
response key; only its type differs (string vs. list vs. nested object). The subclasses pin that type, so
resp.response is always the right shape for the method you called.
No cross‑model inheritance for payload types. Each concrete response declares its own
response field (rather than a shared Any) so the type system and the JSON schema reflect the real payload.
🚀 Usage examples#
Access fields (typed)#
from llm_router_lib import LLMRouterClient
client = LLMRouterClient(api="http://localhost:8080", token="...")
# Conversation — a single reply string (named keyword arguments)
conv = client.conversation_with_model(
user_last_statement="Hello!",
model="google/gemma-3-12b-it",
)
print(conv.response) # "Hi! How can I help?"
print(conv.generation_time) # 0.42 (seconds)
# Translate — one item per input text
tr = client.translate(texts=["Hello world", "Thank you"], model="speakleash/Bielik-11B-v2.3-Instruct")
for item in tr.response:
print(item.original, "->", item.translated)
# Polarity — one item per input text
pol = client.polarity_3c(texts=["Great product!", "Terrible."], model="google/gemma-3-12b-it")
print(pol.response[0].polarity) # "positive"
# Article — nested object
art = client.generate_article_from_text(text="…", model="google/gemma-3-12b-it")
print(art.response.article_text)
Serialize / export#
# To a plain dict
d = tr.model_dump() # {"response": [{"original":…, "translated":…}], "generation_time": …}
# To JSON
s = tr.model_dump_json() # '...'
# To a JSON Schema (for docs / codegen / contract tests)
schema = TranslateResponse.model_json_schema()
Round‑trip (validate a stored/status‑wrapped body)#
from llm_router_lib.data_models.response import TranslateResponse
body = {"status": True,
"response": [{"original": "Hi", "translated": "Hej"}]}
tr = TranslateResponse.model_validate(body) # extra "status" key is ignored
print(tr.response[0].translated) # "Hej"
🔁 Backward compatibility#
The change is source‑breaking for code that treated the return value as a dict:
| Before | After | Migration |
|---|---|---|
resp["response"] |
resp.response |
attribute access |
resp["response"][0]["translated"] |
resp.response[0].translated |
attribute access |
resp["generation_time"] |
resp.generation_time |
attribute access |
resp.get("response") |
resp.response (already None/[] when absent) |
no guard needed |
client.models() → List[str] |
client.models() → ModelsListResponse |
use .ids for the old list |
If you still need a dict, call model_dump() on the result. The integration helper
llm_router_lib/tests/llm_router_client.py already does this automatically before
json.dumps(...).
➕ Adding a new endpoint model#
When a new router endpoint is added, mirror the pattern:
- Define the model in
data_models/response.py:- subclass
GenerationResponsefor a generative endpoint and declareresponsewith the correct type, or subclassBaseResponsefor a health/meta style endpoint.
- subclass
- Export it from
data_models/__init__.py(add the import + the__all__entry). - Wire the client method in
client.py:- import the model,
- annotate the method's return type,
- return
YourModel.model_validate(Service(self.http, self.logger).call_post(payload)).
- Document it in the table above and add the field to the reference section.
- Test both the canonical payload and a partial/
status‑wrapped payload.
✅ Testing notes#
llm_router_api/tests/test_*.py— the client tests now assertisinstance(resp, <Response>)and check the typed fields (resp.response,resp.response[0].translated, …) rather than dict equality.- Models validate both the canonical
{"response": …, "generation_time": …}body and a partial{"status": true, "response": …}body. llm_router_lib/tests/llm_router_client.pyserialises model results withmodel_dump()before printing JSON.
🧾 File map#
| File | Responsibility |
|---|---|
llm_router_lib/data_models/response.py |
All response model definitions |
llm_router_lib/data_models/__init__.py |
Re‑exports the response models |
llm_router_lib/client.py |
LLMRouterClient — every method returns a response model |
llm_router_api/endpoints/builtin/*.py |
Server side — the source of the response shapes the models mirror |