Wire the get_weather tool into the weather assistant with idempotent execution
Python · LLM apps · advanced · greenfield
Wires the weather assistant to the `get_weather` tool: the model gets the tool declaration, and when it asks for a call the endpoint validates the produced arguments against the declared schema, runs the lookup and feeds the result back per the tool-call contract — assistant message first, each result with its matching `tool_call_id` — looping until a final answer or the 5-round cap. Duplicate `tool_call_id` values are refused so a re-emitted call can't run the tool twice. Invalid arguments go back to the model as structured errors instead of crashing the request.
A customer-facing weather bot. The model decides when to call the tool and with what arguments. The tool is a mock — it returns canned weather data — but the surrounding validation, message ordering, and idempotency logic is production-grade. A real `get_weather` would call a paid external API, so double-execution is a real cost and correctness concern.
Requirements
- `POST /weather/chat` accepts `{"message": string}` (1–2000 characters, bounds enforced by FastAPI validation) and returns `{"answer": string}`. The assistant has exactly one tool, `get_weather`, declared by the JSON schema in `WEATHER_TOOL`: required `location` (non-empty string, `minLength: 1`), optional `units` (one of `celsius`, `fahrenheit`, default `celsius`), optional `forecast_days` (integer 1–7, default 1), and `additionalProperties: false`.
- `tool_call.function.arguments` is an untrusted JSON **string** produced by the model. The parsed arguments must be validated against that declared schema BEFORE the tool runs. Invalid arguments — malformed JSON, a non-object, a missing or empty `location`, wrong types (including `forecast_days` as a bool, a string, or a non-integral number), `units` outside the enum, `forecast_days` outside 1–7, or any unexpected property — must come back to the model as the content of the matching `role: "tool"` message in the form `{"error": "…"}` (so it can recover, typically by asking for the location). They must never raise an exception, fail the HTTP request, or reach the tool.
- `get_weather` itself may trust its keyword arguments — validation is the caller's job, as its docstring documents. It returns a mock forecast dict; a real implementation would call an external weather API.
- Each `tool_call_id` is handled at most once per request. A re-emitted call (the same `tool_call_id` seen again in the same request loop, whether or not its first occurrence executed) returns `{"error": "…"}` and skips execution — the tool must not run for that id a second time.
- Message order follows the chat-completions contract: the assistant message carrying `tool_calls` is appended before any of its `role: "tool"` results, and each result carries its own `tool_call_id`. A model response without tool calls ends the loop and its content is returned (`""` when the content is `None`).
- At most `MAX_TOOL_ROUNDS = 5` model calls per request; if the model is still requesting tools after the fifth call the endpoint responds HTTP 502. Provider exceptions (`RateLimitError`, `APITimeoutError`, `APIStatusError`) also respond 502; the SDK's two built-in retries are the only retry mechanism.
- The model is the pinned dated snapshot `gpt-4o-mini-2024-07-18`.
- The system prompt is a constant. User, document and tool text only ever enter as `user`/`tool` messages — never the system prompt.
Files touched
- app/weather_tools.py
- app/weather_agent.py
--- app/weather_tools.py
+"""Weather tool: the schema declaration and the mock implementation."""
+
+from __future__ import annotations
+
+from typing import Any
+
+# Tool declaration sent with every model call.
+# The declared schema is also the validation contract for model-produced arguments.
+WEATHER_TOOL: dict[str, Any] = {
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "description": (
+ "Get the current weather and optionally a multi-day forecast "
+ "for a location."
+ ),
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "location": {
+ "type": "string",
+ "minLength": 1,
+ "description": "City name, e.g. 'London' or 'Tokyo'.",
+ },
+ "units": {
+ "type": "string",
+ "enum": ["celsius", "fahrenheit"],
+ "default": "celsius",
+ "description": "Temperature unit.",