from llmsdk import HermesClient, LLMMessage, ResultRequest, Router
Root cause: the SDK's wire contract is LLMCall.messages: list[dict] and ResultRequest.result: dict, but the shipped example (a) handed pydantic LLMMessage objects into messages (pydantic v2 raises ValidationError — dict_type — for a BaseModel where a plain dict is expected), and (b) read the answer via attribute access on a plain dict (AttributeError). The router then caught the ValidationError and answered HTTP 200 {"status": "END"}, silently masking the failure.
Fix (files in ~/work/python-sdk-example-llmcalls-dicts/):
# sdk_example.py — FIXED (the only lines that changed)
from llm_sdk import HermesClient, LLMMessage, ResultRequest, Router
def run_example(client: HermesClient, user_question: str = "What is 2+2?") -> dict:
# FIX 1: build messages as list[dict] — never pass pydantic objects
messages: list[dict[str, str]] = [
LLMMessage(role="system", content="You are a helpful assistant.").model_dump(),
LLMMessage(role="user", content=user_question).model_dump(),
]
response = Router(client).handle({
"request_id": "req-1",
"result": {"model": "hermes-mini", "messages": messages},
})
# FIX 2: result is a plain dict — use .get(), never attribute access
answer = response.body.get("content", "<no content>")
return {"status": response.status, "answer": answer}
# llm_sdk.py — Router fix (surface errors, never a silent 200)
class Router:
def handle(self, payload: dict) -> RouteResponse:
try:
req = ResultRequest.model_validate(payload) # result is a dict
call = LLMCall.model_validate(req.result) # messages must be list[dict]
raw = self._client.llm_call(call.model, call.messages, call.temperature)
out = ResultRequest.model_validate(raw)
return RouteResponse(status=200, body=out.result)
except ValidationError as exc:
# FIX: explicit HTTP 400 + ERROR body. Old code returned 200 {"status":"END"}.
return RouteResponse(status=400, body={
"status": "ERROR",
"detail": "request failed validation",
"errors": [str(e) for e in exc.errors()],
})
Regression tests added (test_sdk_example.py, all run against MockHermes):
# 1. list[dict] messages pass gateway validation end-to-end
def test_messages_built_as_list_of_dicts_validates(): ...
# 2. LLMMessage objects in messages:list[dict] -> ValidationError (root cause)
def test_llmmessage_objects_rejected_by_wire_contract(): ...
# 3. result is a plain dict: .get() works, attribute access -> AttributeError
def test_result_result_is_plain_dict_get_access(): ...
# 4. Router returns 400 ERROR on ValidationError — never silent 200 END
def test_router_surfaces_validation_error_not_silent_200(): ...
MockHermes (mock_hermes.py) stands in for the gateway: it strictly validates the incoming payload with LLMCall.model_validate, records every call, and returns a ResultRequest envelope — so a regression is caught at the exact layer where production failed.
**Verified by running** `pytest` in the project venv (pydantic 2.13.4, Python 3.14.4):
```
test_messages_built_as_list_of_dicts_validates PASSED
test_llmmessage_objects_rejected_by_wire_contract PASSED
test_result_result_is_plain_dict_get_access PASSED
test_router_surfaces_validation_error_not_silent_200 PASSED
test_buggy_example_crashes_fixed_example_runs PASSED
5 passed in 0.04s
```
**Edge cases tested (REPL-level smoke):**
- Empty `messages` list → valid, HTTP 200, gateway records `prompt_tokens: 0`.
- `result` dict missing `content` → `.get("content", "<no content>")` returns the fallback, no crash.
- Non-dict element (`42`) in `messages` → router returns **400 ERROR**; the legacy `handle_silent_200` path confirms the old masking behavior (200 + END) the fix removes.
- Malformed envelope (missing `request_id`) → 400 ERROR, not 200.
- `LLMMessage.model_dump()` round-trip preserves non-user roles (`tool`), i.e. conversion is lossless.
- Pydantic v2 confirmed: `LLMCall(messages=[LLMMessage(...)])` raises `ValidationError` (`type: "dict_type"`, `loc: ("messages", 0)`), while dicts validate cleanly; `reply.result.content` raises `AttributeError`, `.get("content")` returns `"42"`.{"model": "deepseek-v4-flash", "problem_class": "python-sdk-example-llmcalls-dicts", "result": "passed", "tests": 110}Root cause: the SDK's wire contract is LLMCall.messages: list[dict] and ResultRequest.result: dict, but the shipped example (a) handed pydantic LLMMessage objects into messages (pydantic v2 raises ValidationError — dict_type — for a BaseModel where a plain dict is expected), and (b) read the answer via attribute access on a plain dict (AttributeError). The router then caught the ValidationError and answered HTTP 200 {"status": "END"}, silently masking the failure.
Fix (files in ~/work/python-sdk-example-llmcalls-dicts/):
# sdk_example.py — FIXED (the only lines that changed)
from llm_sdk import HermesClient, LLMMessage, ResultRequest, Router
def run_example(client: HermesClient, user_question: str = "What is 2+2?") -> dict:
# FIX 1: build messages as list[dict] — never pass pydantic objects
messages: list[dict[str, str]] = [
LLMMessage(role="system", content="You are a helpful assistant.").model_dump(),
LLMMessage(role="user", content=user_question).model_dump(),
]
response = Router(client).handle({
"request_id": "req-1",
"result": {"model": "hermes-mini", "messages": messages},
})
# FIX 2: result is a plain dict — use .get(), never attribute access
answer = response.body.get("content", "<no content>")
return {"status": response.status, "answer": answer}
# llm_sdk.py — Router fix (surface errors, never a silent 200)
class Router:
def handle(self, payload: dict) -> RouteResponse:
try:
req = ResultRequest.model_validate(payload) # result is a dict
call = LLMCall.model_validate(req.result) # messages must be list[dict]
raw = self._client.llm_call(call.model, call.messages, call.temperature)
out = ResultRequest.model_validate(raw)
return RouteResponse(status=200, body=out.result)
except ValidationError as exc:
# FIX: explicit HTTP 400 + ERROR body. Old code returned 200 {"status":"END"}.
return RouteResponse(status=400, body={
"status": "ERROR",
"detail": "request failed validation",
"errors": [str(e) for e in exc.errors()],
})
Regression tests added (test_sdk_example.py, all run against MockHermes):
# 1. list[dict] messages pass gateway validation end-to-end
def test_messages_built_as_list_of_dicts_validates(): ...
# 2. LLMMessage objects in messages:list[dict] -> ValidationError (root cause)
def test_llmmessage_objects_rejected_by_wire_contract(): ...
# 3. result is a plain dict: .get() works, attribute access -> AttributeError
def test_result_result_is_plain_dict_get_access(): ...
# 4. Router returns 400 ERROR on ValidationError — never silent 200 END
def test_router_surfaces_validation_error_not_silent_200(): ...
MockHermes (mock_hermes.py) stands in for the gateway: it strictly validates the incoming payload with LLMCall.model_validate, records every call, and returns a ResultRequest envelope — so a regression is caught at the exact layer where production failed.
**Verified by running** `pytest` in the project venv (pydantic 2.13.4, Python 3.14.4):
```
test_messages_built_as_list_of_dicts_validates PASSED
test_llmmessage_objects_rejected_by_wire_contract PASSED
test_result_result_is_plain_dict_get_access PASSED
test_router_surfaces_validation_error_not_silent_200 PASSED
test_buggy_example_crashes_fixed_example_runs PASSED
5 passed in 0.04s
```
**Edge cases tested (REPL-level smoke):**
- Empty `messages` list → valid, HTTP 200, gateway records `prompt_tokens: 0`.
- `result` dict missing `content` → `.get("content", "<no content>")` returns the fallback, no crash.
- Non-dict element (`42`) in `messages` → router returns **400 ERROR**; the legacy `handle_silent_200` path confirms the old masking behavior (200 + END) the fix removes.
- Malformed envelope (missing `request_id`) → 400 ERROR, not 200.
- `LLMMessage.model_dump()` round-trip preserves non-user roles (`tool`), i.e. conversion is lossless.
- Pydantic v2 confirmed: `LLMCall(messages=[LLMMessage(...)])` raises `ValidationError` (`type: "dict_type"`, `loc: ("messages", 0)`), while dicts validate cleanly; `reply.result.content` raises `AttributeError`, `.get("content")` returns `"42"`.{"model": "deepseek-v4-flash", "problem_class": "python-sdk-example-llmcalls-dicts", "result": "passed", "tests": 110}