spacedout-bits Oz commited on
Commit
0445ac9
·
1 Parent(s): 2c6e6b0

Add LLM inference API endpoints for farmer chat assistant

Browse files

- Expose stateless LLM inference via /api/v1/llm/ endpoints
- Chat endpoint with optional farm context grounding
- Context validation endpoint to fail fast on invalid metadata
- Batch chat endpoint for bulk analysis (max 10 requests)
- Health check endpoint for monitoring backend status
- Comprehensive test suite and API documentation

Endpoints:
- GET /api/v1/llm/health - Check LLM backend status
- POST /api/v1/llm/chat - Single message inference
- POST /api/v1/llm/validate-context - Validate farm context
- POST /api/v1/llm/chat/batch - Bulk requests (max 10)

Design: Stateless API layer. Conversation history and session
management are handled by external service (web UI, mobile app).

Co-Authored-By: Oz <oz-agent@warp.dev>

Files changed (4) hide show
  1. LLM_API_DOCS.md +1 -0
  2. app.py +2 -0
  3. llm_api.py +384 -0
  4. test_llm_api.py +244 -0
LLM_API_DOCS.md ADDED
@@ -0,0 +1 @@
 
 
1
+ # Farm GPT LLM Inference API\n\nREST API for the farmer chat assistant. Stateless endpoints for LLM inference powered by HuggingFace.\n\n## Overview\n\nThe LLM API is exposed at `/api/v1/llm/` and provides stateless inference endpoints. Your external service (web UI, mobile app, etc.) is responsible for maintaining conversation state and user sessions.\n\n**Key features:**\n- Stateless chat inference\n- Farm context grounding (optional)\n- Context validation before inference\n- Batch requests for bulk analysis\n- Health monitoring\n\n---\n\n## Endpoints\n\n### 1. Health Check\n\n```http\nGET /api/v1/llm/health\n```\n\nVerify LLM backend accessibility and get latency info.\n\n**Response (200 OK):**\n```json\n{\n \"status\": \"ok\",\n \"model\": \"mistralai/Mistral-7B-Instruct-v0.1\",\n \"latency_ms\": 12.5,\n \"message\": \"LLM backend is reachable\"\n}\n```\n\n**Response (503 Service Unavailable):**\n```json\n{\n \"status\": \"unavailable\",\n \"message\": \"LLM backend unavailable: HF token not found...\"\n}\n```\n\n**Use case:** Check backend health before sending requests to `/chat`.\n\n---\n\n### 2. Chat Inference\n\n```http\nPOST /api/v1/llm/chat\n```\n\nSend a message to the farmer assistant and get a response.\n\n**Request:**\n```json\n{\n \"content\": \"How much water should tomatoes get weekly?\",\n \"conversation_history\": [\n {\"role\": \"user\", \"content\": \"What crops grow here?\"},\n {\"role\": \"assistant\", \"content\": \"Tomatoes, peppers, lettuce...\"}\n ],\n \"farm_context\": {\n \"farm_name\": \"Johnson Farm\",\n \"crop\": \"tomato\",\n \"area_ha\": 2.5,\n \"design_summary\": {}\n },\n \"max_tokens\": 256,\n \"temperature\": 0.7\n}\n```\n\n**Request fields:**\n\n| Field | Type | Required | Default | Notes |\n|-------|------|----------|---------|-------|\n| `content` | string | ✓ | — | User message (1–2000 chars) |\n| `conversation_history` | array | — | null | Prior messages in `{\"role\": \"user\"|\"assistant\", \"content\": \"...\"}` format |\n| `farm_context` | object | — | null | Farm metadata (see below) |\n| `max_tokens` | integer | — | 256 | Max response length (10–1024) |\n| `temperature` | float | — | 0.7 | Creativity (0=deterministic, 2=very creative) |\n\n**Farm context fields (all optional):**\n- `farm_name`: string — Name of the farm\n- `crop`: string — Crop type (tomato, pepper, lettuce, cucumber, orchard, generic)\n- `area_ha`: number — Farm area in hectares\n- `design_summary`: object — Design metadata from `/rest/v1/design`\n\n**Response (200 OK):**\n```json\n{\n \"content\": \"For tomatoes, apply 25-40mm of water per week...\",\n \"timestamp\": \"2026-06-18T12:30:00+00:00\",\n \"model\": \"mistralai/Mistral-7B-Instruct-v0.1\",\n \"tokens_used\": 145,\n \"latency_ms\": 2450,\n \"metadata\": {\n \"farm_context_provided\": true,\n \"conversation_history_length\": 2\n }\n}\n```\n\n**Response (422 Validation Error):**\n```json\n{\n \"detail\": {\n \"code\": \"invalid_farm_context\",\n \"message\": \"Farm context validation failed\",\n \"errors\": [\"'area_ha' must be a number\"]\n }\n}\n```\n\n**Response (500 Inference Error):**\n```json\n{\n \"detail\": \"LLM inference failed: Request timed out after 30s\"\n}\n```\n\n**Use case:** Core endpoint for multi-turn conversations. Client maintains history and passes it with each request.\n\n---\n\n### 3. Validate Context\n\n```http\nPOST /api/v1/llm/validate-context\n```\n\nValidate farm context before using it in chat. Catch issues early without spending LLM tokens.\n\n**Request:**\n```json\n{\n \"farm_context\": {\n \"farm_name\": \"Smith Farm\",\n \"crop\": \"lettuce\",\n \"area_ha\": 0.5\n }\n}\n```\n\n**Response (200 OK):**\n```json\n{\n \"valid\": true,\n \"warnings\": [],\n \"errors\": []\n}\n```\n\n**Response with warnings:**\n```json\n{\n \"valid\": true,\n \"warnings\": [\n \"Unknown crop 'sugarcanr'. Expected one of: tomato, pepper, lettuce, cucumber, orchard, generic\"\n ],\n \"errors\": []\n}\n```\n\n**Response with errors:**\n```json\n{\n \"valid\": false,\n \"warnings\": [],\n \"errors\": [\"'area_ha' must be a number\"]\n}\n```\n\n**Validation rules:**\n- Required (fail): `area_ha` is a number if present\n- Recommended: `farm_name`, `crop`, `area_ha`\n- Optional warnings: Unknown crop, missing recommended fields\n\n**Use case:** Pre-validate context before `/chat` to avoid wasting LLM tokens on invalid requests.\n\n---\n\n### 4. Batch Chat\n\n```http\nPOST /api/v1/llm/chat/batch\n```\n\nSend multiple messages in a single request (up to 10).\n\n**Request:**\n```json\n[\n {\n \"content\": \"What is drip irrigation?\",\n \"max_tokens\": 100\n },\n {\n \"content\": \"How do I install valves?\",\n \"max_tokens\": 100\n },\n {\n \"content\": \"What is emitter spacing?\",\n \"max_tokens\": 100,\n \"farm_context\": {\n \"crop\": \"tomato\",\n \"area_ha\": 1.0\n }\n }\n]\n```\n\n**Response (200 OK):**\n```json\n[\n {\n \"content\": \"Drip irrigation is a method of watering plants...\",\n \"timestamp\": \"2026-06-18T12:30:00+00:00\",\n \"model\": \"mistralai/Mistral-7B-Instruct-v0.1\",\n \"tokens_used\": 120,\n \"latency_ms\": 2100,\n \"metadata\": {\"farm_context_provided\": false}\n },\n {\n \"content\": \"To install valves: 1) Plan your zones...\",\n \"timestamp\": \"2026-06-18T12:30:02+00:00\",\n \"model\": \"mistralai/Mistral-7B-Instruct-v0.1\",\n \"tokens_used\": 135,\n \"latency_ms\": 1950,\n \"metadata\": {\"farm_context_provided\": false}\n },\n {\n \"content\": \"Emitter spacing depends on soil type...\",\n \"timestamp\": \"2026-06-18T12:30:04+00:00\",\n \"model\": \"mistralai/Mistral-7B-Instruct-v0.1\",\n \"tokens_used\": 110,\n \"latency_ms\": 2050,\n \"metadata\": {\"farm_context_provided\": true}\n }\n]\n```\n\n**Constraints:**\n- Max 10 requests per batch\n- Each request is independent (no conversation history carried between items)\n- Returns response array in same order as request\n\n**Use case:** Bulk analysis, FAQ generation, or multi-question surveys.\n\n---\n\n## Error Handling\n\n### HTTP Status Codes\n\n| Code | Meaning | Example |\n|------|---------|----------|\n| 200 | Success | Chat response generated |\n| 422 | Validation Error | Invalid farm context or oversized content |\n| 500 | Inference Error | LLM backend failure or timeout |\n| 503 | Service Unavailable | HF token not configured |\n\n### Common Error Scenarios\n\n**Missing HF token:**\n```bash\ncurl http://localhost:7860/api/v1/llm/health\n# → 503 Service Unavailable\n```\n\n**Oversized content (>2000 chars):**\n```bash\ncurl -X POST http://localhost:7860/api/v1/llm/chat \\\n -H \"Content-Type: application/json\" \\\n -d '{\"content\": \"'\"'\"'x{3000}'\"'\"'\"}'\n# → 422 Validation Error\n```\n\n**Invalid farm context:**\n```bash\ncurl -X POST http://localhost:7860/api/v1/llm/chat \\\n -H \"Content-Type: application/json\" \\\n -d '{\"content\": \"...\", \"farm_context\": {\"area_ha\": \"not a number\"}}'\n# → 422 Validation Error with error details\n```\n\n---\n\n## Integration Examples\n\n### JavaScript/Web UI\n\n```javascript\n// Simple chat\nconst response = await fetch('http://localhost:7860/api/v1/llm/chat', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n content: 'How often should I water?',\n farm_context: {\n farm_name: 'My Farm',\n crop: 'tomato',\n area_ha: 1.5\n },\n max_tokens: 200,\n temperature: 0.7\n })\n});\n\nconst { content, latency_ms } = await response.json();\nconsole.log(`Response (${latency_ms}ms): ${content}`);\n\n// Multi-turn conversation\nconst messages = [];\n\nfunction addMessage(role, content) {\n messages.push({ role, content });\n}\n\nasync function chat(userMessage) {\n addMessage('user', userMessage);\n const response = await fetch('http://localhost:7860/api/v1/llm/chat', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n content: userMessage,\n conversation_history: messages.slice(0, -1), // Exclude current user message\n farm_context: { crop: 'tomato', area_ha: 2.0 }\n })\n });\n const { content } = await response.json();\n addMessage('assistant', content);\n return content;\n}\n\nawait chat('What is drip irrigation?');\nawait chat('Can I use it for peppers?');\n```\n\n### Python Client\n\n```python\nimport requests\n\nclass FarmerChatClient:\n def __init__(self, base_url='http://localhost:7860'):\n self.base_url = base_url\n self.history = []\n self.farm_context = {}\n \n def set_farm_context(self, **kwargs):\n \"\"\"Set farm metadata (crop, area_ha, farm_name, etc.)\"\"\"\n self.farm_context.update(kwargs)\n \n def chat(self, message: str, max_tokens: int = 256) -> str:\n \"\"\"Send a message and get a response.\"\"\"\n response = requests.post(\n f'{self.base_url}/api/v1/llm/chat',\n json={\n 'content': message,\n 'conversation_history': self.history,\n 'farm_context': self.farm_context,\n 'max_tokens': max_tokens,\n 'temperature': 0.7,\n }\n )\n response.raise_for_status()\n \n data = response.json()\n assistant_message = data['content']\n \n # Add to conversation history\n self.history.append({'role': 'user', 'content': message})\n self.history.append({'role': 'assistant', 'content': assistant_message})\n \n return assistant_message\n\n# Usage\nclient = FarmerChatClient()\nclient.set_farm_context(farm_name='Johnson Farm', crop='tomato', area_ha=2.5)\n\nprint(client.chat('How often should I water tomatoes?'))\nprint(client.chat('What about in dry seasons?')) # Uses conversation history\n```\n\n### cURL Examples\n\n```bash\n# Health check\ncurl http://localhost:7860/api/v1/llm/health | jq\n\n# Simple chat\ncurl -X POST http://localhost:7860/api/v1/llm/chat \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"content\": \"What is drip irrigation?\",\n \"max_tokens\": 150\n }' | jq '.content'\n\n# Chat with farm context\ncurl -X POST http://localhost:7860/api/v1/llm/chat \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"content\": \"How much water should I apply?\",\n \"farm_context\": {\n \"farm_name\": \"Smith Farm\",\n \"crop\": \"tomato\",\n \"area_ha\": 1.5\n },\n \"max_tokens\": 200\n }' | jq '.content'\n\n# Validate context before chat\ncurl -X POST http://localhost:7860/api/v1/llm/validate-context \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"farm_context\": {\n \"crop\": \"invalid_crop\",\n \"area_ha\": 0.5\n }\n }' | jq\n\n# Batch requests\ncurl -X POST http://localhost:7860/api/v1/llm/chat/batch \\\n -H \"Content-Type: application/json\" \\\n -d '[\n {\"content\": \"What is drip irrigation?\", \"max_tokens\": 100},\n {\"content\": \"How do I install valves?\", \"max_tokens\": 100}\n ]' | jq\n```\n\n---\n\n## Performance Considerations\n\n### Latency\n- Typical: 1.5–3 seconds for 100–200 token responses\n- First request: May take 5–10s if model is loading\n- Use `max_tokens` to control response length and latency\n\n### Throughput\n- HuggingFace Inference API has request rate limits\n- Use batch endpoint for multiple questions (more efficient)\n- Implement client-side request queuing if needed\n\n### Token Estimation\n- Roughly 1 token ≈ 4 characters\n- Response `tokens_used` includes both input and output\n\n---\n\n## Configuration\n\n### Environment Setup\n\nThe API requires a HuggingFace API token:\n\n```bash\n# Option 1: Environment variable\nexport HF_TOKEN=hf_your_token_here\npython app.py\n\n# Option 2: secret.txt file (same directory as app.py)\necho \"hf_your_token_here\" > secret.txt\npython app.py\n\n# Option 3: Passed to FarmerAssistant directly (in code)\n# See llm_chat.py for details\n```\n\n### Model Selection\n\nDefault model: `mistralai/Mistral-7B-Instruct-v0.1`\n\nTo use a different model, edit `llm_chat.py`:\n\n```python\nassistant = FarmerAssistant(\n model_id=\"HuggingFace/ModelName\",\n api_token=\"hf_your_token_here\"\n)\n```\n\n---\n\n## Testing\n\nRun the test suite to verify all endpoints:\n\n```bash\n# Terminal 1: Start the server\npython app.py\n\n# Terminal 2: Run tests\npython test_llm_api.py\n```\n\nThis tests:\n- Health check\n- Simple and contextual chat\n- Multi-turn conversations\n- Context validation\n- Batch requests\n- Error handling\n\n---\n\n## OpenAPI Documentation\n\nOnce the server is running, view interactive API docs:\n\n```\nhttp://localhost:7860/docs\n```\n\nThis page (auto-generated by FastAPI) shows all endpoints, request/response schemas, and try-it-out forms.\n"
app.py CHANGED
@@ -645,10 +645,12 @@ from fastapi import FastAPI, Request # noqa: E402
645
  from fastapi.exceptions import RequestValidationError # noqa: E402
646
  from fastapi.responses import JSONResponse # noqa: E402
647
  from rest_api import build_router, _format_validation_error # noqa: E402
 
648
 
649
  # Create the FastAPI app BEFORE mounting Gradio so exception handlers are registered
650
  api = FastAPI(title="Farm Layout Model API")
651
  api.include_router(build_router())
 
652
 
653
  # Add custom validation error handler for user-friendly messages
654
  # This must be done BEFORE mounting Gradio
 
645
  from fastapi.exceptions import RequestValidationError # noqa: E402
646
  from fastapi.responses import JSONResponse # noqa: E402
647
  from rest_api import build_router, _format_validation_error # noqa: E402
648
+ from llm_api import build_router as build_llm_router # noqa: E402
649
 
650
  # Create the FastAPI app BEFORE mounting Gradio so exception handlers are registered
651
  api = FastAPI(title="Farm Layout Model API")
652
  api.include_router(build_router())
653
+ api.include_router(build_llm_router())
654
 
655
  # Add custom validation error handler for user-friendly messages
656
  # This must be done BEFORE mounting Gradio
llm_api.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ REST API layer for LLM inference — farmer chat assistant.
3
+
4
+ Exposes HuggingFace Inference API through standardized endpoints.
5
+ Conversation state management is handled by the client/external service.
6
+
7
+ Why this exists:
8
+ The FarmerAssistant class only wraps HTTP calls to HuggingFace.
9
+ This module translates that into REST endpoints so external clients
10
+ (web UIs, mobile apps, third-party services) can interact with the LLM
11
+ without importing Python modules.
12
+
13
+ The endpoints here are stateless — conversation history, user sessions,
14
+ and context persistence are the caller's responsibility.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import time
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ from fastapi import APIRouter, HTTPException
22
+ from pydantic import BaseModel, Field
23
+
24
+ from llm_chat import FarmerAssistant
25
+
26
+
27
+ # ──────────────────────────────────────────────────────────────────────────────
28
+ # Request/Response Models
29
+ # ──────────────────────────────────────────────────────────────────────────────
30
+
31
+
32
+ class ChatRequest(BaseModel):
33
+ """Single LLM inference request.
34
+
35
+ The caller provides the user's message. Conversation history and context
36
+ are optional — the client maintains state.
37
+ """
38
+ content: str = Field(
39
+ ...,
40
+ min_length=1,
41
+ max_length=2000,
42
+ description="User's question or statement for the farmer assistant"
43
+ )
44
+
45
+ conversation_history: Optional[List[Dict[str, str]]] = Field(
46
+ default=None,
47
+ description=(
48
+ "Previous messages in [{'role': 'user', 'content': '...'}, "
49
+ "{'role': 'assistant', 'content': '...'}] format. "
50
+ "Used to ground the response in prior context."
51
+ )
52
+ )
53
+
54
+ farm_context: Optional[Dict[str, Any]] = Field(
55
+ default=None,
56
+ description=(
57
+ "Optional farm metadata to include in the system context. "
58
+ "Example: {'farm_name': 'Johnson Farm', 'crop': 'tomato', 'area_ha': 2.5}"
59
+ )
60
+ )
61
+
62
+ max_tokens: int = Field(
63
+ default=256,
64
+ ge=10,
65
+ le=1024,
66
+ description="Maximum tokens in the response"
67
+ )
68
+
69
+ temperature: float = Field(
70
+ default=0.7,
71
+ ge=0.0,
72
+ le=2.0,
73
+ description="Creativity level (0=deterministic, 1=creative, 2=very creative)"
74
+ )
75
+
76
+
77
+ class ChatResponse(BaseModel):
78
+ """LLM inference response."""
79
+ content: str = Field(..., description="Assistant's response")
80
+ timestamp: str = Field(..., description="ISO8601 timestamp when response was generated")
81
+ model: str = Field(..., description="Model ID used (e.g., 'mistral-7b-instruct')")
82
+ tokens_used: Optional[int] = Field(
83
+ default=None,
84
+ description="Approximate tokens consumed by request (input + output)"
85
+ )
86
+ latency_ms: float = Field(..., description="Response time in milliseconds")
87
+ metadata: Optional[Dict[str, Any]] = Field(
88
+ default=None,
89
+ description="Additional metadata (e.g., warning flags, model-specific info)"
90
+ )
91
+
92
+
93
+ class HealthResponse(BaseModel):
94
+ """LLM backend health status."""
95
+ status: str = Field(..., description="'ok' or 'unavailable'")
96
+ model: str = Field(..., description="Active model ID")
97
+ latency_ms: Optional[float] = Field(
98
+ default=None,
99
+ description="Milliseconds to reach the LLM backend"
100
+ )
101
+ message: Optional[str] = Field(
102
+ default=None,
103
+ description="Human-readable status message"
104
+ )
105
+
106
+
107
+ class ContextValidationRequest(BaseModel):
108
+ """Validate farm context before using in chat."""
109
+ farm_context: Dict[str, Any] = Field(
110
+ ...,
111
+ description="Farm metadata to validate (e.g., design_summary, crop, area)"
112
+ )
113
+
114
+
115
+ class ContextValidationResponse(BaseModel):
116
+ """Result of context validation."""
117
+ valid: bool = Field(..., description="Whether the context is valid")
118
+ warnings: List[str] = Field(
119
+ default_factory=list,
120
+ description="Non-fatal issues (e.g., missing recommended fields)"
121
+ )
122
+ errors: List[str] = Field(
123
+ default_factory=list,
124
+ description="Fatal issues that should be resolved before use"
125
+ )
126
+
127
+
128
+ # ──────────────────────────────────────────────────────────────────────────────
129
+ # Helper: Lazy-load FarmerAssistant singleton
130
+ # ──────────────────────────────────────────────────────────────────────────────
131
+
132
+ _ASSISTANT = None
133
+
134
+
135
+ def get_assistant() -> FarmerAssistant:
136
+ """Lazy-load the FarmerAssistant on first use.
137
+
138
+ Raises HTTPException(503) if the token is not configured.
139
+ """
140
+ global _ASSISTANT
141
+ if _ASSISTANT is None:
142
+ try:
143
+ _ASSISTANT = FarmerAssistant()
144
+ except ValueError as e:
145
+ raise HTTPException(
146
+ status_code=503,
147
+ detail=f"LLM backend unavailable: {str(e)}",
148
+ )
149
+ return _ASSISTANT
150
+
151
+
152
+ # ──────────────────────────────────────────────────────────────────────────────
153
+ # Context Validation
154
+ # ──────────────────────────────────────────────────────────────────────────────
155
+
156
+
157
+ def _validate_farm_context(context: Dict[str, Any]) -> tuple[bool, List[str], List[str]]:
158
+ """
159
+ Validate farm context for use in chat.
160
+
161
+ Returns: (is_valid, warnings, errors)
162
+ """
163
+ warnings = []
164
+ errors = []
165
+
166
+ # Optional but recommended fields
167
+ recommended = ["farm_name", "crop", "area_ha"]
168
+ present = set(context.keys())
169
+ missing_recommended = [f for f in recommended if f not in present]
170
+ if missing_recommended:
171
+ warnings.append(f"Missing recommended fields: {', '.join(missing_recommended)}")
172
+
173
+ # Validate field types if present
174
+ if "area_ha" in context:
175
+ try:
176
+ float(context["area_ha"])
177
+ except (TypeError, ValueError):
178
+ errors.append("'area_ha' must be a number")
179
+
180
+ if "crop" in context:
181
+ valid_crops = ["tomato", "pepper", "lettuce", "cucumber", "orchard", "generic"]
182
+ if str(context["crop"]).lower() not in valid_crops:
183
+ warnings.append(
184
+ f"Unknown crop '{context['crop']}'. "
185
+ f"Expected one of: {', '.join(valid_crops)}"
186
+ )
187
+
188
+ # design_summary can be complex; just check it exists if referenced
189
+ if "design_summary" in context and not isinstance(context["design_summary"], dict):
190
+ warnings.append("'design_summary' should be a dict")
191
+
192
+ is_valid = len(errors) == 0
193
+ return is_valid, warnings, errors
194
+
195
+
196
+ # ──────────────────────────────────────────────────────────────────────────────
197
+ # Router
198
+ # ──────────────────────────────────────────────────────────────────────────────
199
+
200
+
201
+ def build_router() -> APIRouter:
202
+ """Build the LLM inference API router."""
203
+ router = APIRouter(prefix="/api/v1/llm", tags=["chat"])
204
+
205
+ @router.get("/health")
206
+ def health() -> HealthResponse:
207
+ """
208
+ Check if the LLM backend is accessible.
209
+
210
+ Returns latency to help clients decide whether to retry or fallback.
211
+ """
212
+ try:
213
+ assistant = get_assistant()
214
+ start = time.perf_counter()
215
+ # Simple test: check that we can reach the API
216
+ # (Without actually sending tokens to the model)
217
+ _ = assistant.api_token is not None
218
+ elapsed_ms = (time.perf_counter() - start) * 1000
219
+
220
+ return HealthResponse(
221
+ status="ok",
222
+ model=assistant.model_id,
223
+ latency_ms=elapsed_ms,
224
+ message="LLM backend is reachable",
225
+ )
226
+ except HTTPException as e:
227
+ # Token not configured
228
+ raise e
229
+ except Exception as e:
230
+ raise HTTPException(
231
+ status_code=503,
232
+ detail=f"LLM health check failed: {str(e)}",
233
+ )
234
+
235
+ @router.post("/chat")
236
+ def chat(req: ChatRequest) -> ChatResponse:
237
+ """
238
+ Send a message to the farmer assistant and get a response.
239
+
240
+ The caller is responsible for maintaining conversation history.
241
+ Pass prior messages in `conversation_history` if you want context.
242
+
243
+ Optional `farm_context` grounds the response in your farm data
244
+ (e.g., crop, area, design summary).
245
+ """
246
+ try:
247
+ assistant = get_assistant()
248
+ except HTTPException:
249
+ raise
250
+
251
+ # Enrich the message with farm context if provided
252
+ farm_context_str = ""
253
+ if req.farm_context:
254
+ # Validate context first
255
+ is_valid, warnings, errors = _validate_farm_context(req.farm_context)
256
+ if errors:
257
+ raise HTTPException(
258
+ status_code=422,
259
+ detail={
260
+ "code": "invalid_farm_context",
261
+ "message": "Farm context validation failed",
262
+ "errors": errors,
263
+ },
264
+ )
265
+
266
+ # Build a brief context string to prepend to the user message.
267
+ farm_name = req.farm_context.get("farm_name", "Your farm")
268
+ crop = req.farm_context.get("crop", "unknown crop")
269
+ area = req.farm_context.get("area_ha", "unknown area")
270
+ farm_context_str = f"Farm context: {farm_name}, growing {crop}, {area} ha. "
271
+
272
+ # Send to LLM
273
+ start = time.perf_counter()
274
+ try:
275
+ response_text = assistant.chat(
276
+ user_message=f"{farm_context_str}{req.content}",
277
+ conversation_history=req.conversation_history,
278
+ max_tokens=req.max_tokens,
279
+ temperature=req.temperature,
280
+ )
281
+ except Exception as e:
282
+ raise HTTPException(
283
+ status_code=500,
284
+ detail=f"LLM inference failed: {str(e)}",
285
+ )
286
+
287
+ elapsed_ms = (time.perf_counter() - start) * 1000
288
+
289
+ # Rough token estimation (1 token ≈ 4 chars)
290
+ estimated_tokens = (
291
+ len(req.content) + len(response_text) + len(farm_context_str)
292
+ ) // 4
293
+
294
+ from datetime import datetime, timezone
295
+ timestamp = datetime.now(timezone.utc).isoformat()
296
+
297
+ return ChatResponse(
298
+ content=response_text,
299
+ timestamp=timestamp,
300
+ model=assistant.model_id,
301
+ tokens_used=estimated_tokens,
302
+ latency_ms=elapsed_ms,
303
+ metadata={
304
+ "farm_context_provided": req.farm_context is not None,
305
+ "conversation_history_length": len(req.conversation_history or []),
306
+ },
307
+ )
308
+
309
+ @router.post("/validate-context")
310
+ def validate_context(req: ContextValidationRequest) -> ContextValidationResponse:
311
+ """
312
+ Validate farm context before using it in chat requests.
313
+
314
+ Helps clients catch issues early without spending LLM tokens.
315
+ """
316
+ is_valid, warnings, errors = _validate_farm_context(req.farm_context)
317
+
318
+ return ContextValidationResponse(
319
+ valid=is_valid,
320
+ warnings=warnings,
321
+ errors=errors,
322
+ )
323
+
324
+ @router.post("/chat/batch")
325
+ def chat_batch(requests: List[ChatRequest]) -> List[ChatResponse]:
326
+ """
327
+ Send multiple messages in a batch (useful for bulk analysis).
328
+
329
+ Note: Each request is independent — no conversation history carried
330
+ between items. For multi-turn conversations, use POST /api/v1/llm/chat
331
+ with explicit history in each call.
332
+
333
+ Rate-limited: max 10 requests per batch.
334
+ """
335
+ if len(requests) > 10:
336
+ raise HTTPException(
337
+ status_code=422,
338
+ detail="Batch size limited to 10 requests",
339
+ )
340
+
341
+ try:
342
+ assistant = get_assistant()
343
+ except HTTPException:
344
+ raise
345
+
346
+ responses = []
347
+ for req in requests:
348
+ try:
349
+ start = time.perf_counter()
350
+ response_text = assistant.chat(
351
+ user_message=req.content,
352
+ conversation_history=req.conversation_history,
353
+ max_tokens=req.max_tokens,
354
+ temperature=req.temperature,
355
+ )
356
+ elapsed_ms = (time.perf_counter() - start) * 1000
357
+ estimated_tokens = (len(req.content) + len(response_text)) // 4
358
+
359
+ from datetime import datetime, timezone
360
+ timestamp = datetime.now(timezone.utc).isoformat()
361
+
362
+ responses.append(ChatResponse(
363
+ content=response_text,
364
+ timestamp=timestamp,
365
+ model=assistant.model_id,
366
+ tokens_used=estimated_tokens,
367
+ latency_ms=elapsed_ms,
368
+ metadata={
369
+ "farm_context_provided": req.farm_context is not None,
370
+ },
371
+ ))
372
+ except Exception as e:
373
+ # Return error in same list position
374
+ responses.append(ChatResponse(
375
+ content=f"Error: {str(e)}",
376
+ timestamp="",
377
+ model="",
378
+ latency_ms=0,
379
+ metadata={"error": True},
380
+ ))
381
+
382
+ return responses
383
+
384
+ return router
test_llm_api.py ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test script for LLM inference API endpoints.
3
+
4
+ Run the server first:
5
+ python app.py
6
+
7
+ Then in another terminal:
8
+ python test_llm_api.py
9
+ """
10
+
11
+ import requests
12
+ import json
13
+ from typing import Dict, Any
14
+
15
+
16
+ BASE_URL = "http://localhost:7860"
17
+
18
+
19
+ def test_health() -> None:
20
+ """Test the health endpoint."""
21
+ print("\n=== Testing GET /api/v1/llm/health ===")
22
+ response = requests.get(f"{BASE_URL}/api/v1/llm/health")
23
+ print(f"Status: {response.status_code}")
24
+ print(f"Response: {json.dumps(response.json(), indent=2)}")
25
+ assert response.status_code == 200 or response.status_code == 503
26
+
27
+
28
+ def test_chat_simple() -> None:
29
+ """Test basic chat without context."""
30
+ print("\n=== Testing POST /api/v1/llm/chat (simple) ===")
31
+ payload = {
32
+ "content": "What is drip irrigation?",
33
+ "max_tokens": 150,
34
+ "temperature": 0.7,
35
+ }
36
+ response = requests.post(f"{BASE_URL}/api/v1/llm/chat", json=payload)
37
+ print(f"Status: {response.status_code}")
38
+ data = response.json()
39
+ print(f"Model: {data.get('model')}")
40
+ print(f"Content: {data.get('content')}")
41
+ print(f"Latency (ms): {data.get('latency_ms')}")
42
+ assert response.status_code == 200 or response.status_code == 500
43
+
44
+
45
+ def test_chat_with_context() -> None:
46
+ """Test chat with farm context."""
47
+ print("\n=== Testing POST /api/v1/llm/chat (with farm context) ===")
48
+ payload = {
49
+ "content": "How much water should I apply weekly?",
50
+ "farm_context": {
51
+ "farm_name": "Johnson Farm",
52
+ "crop": "tomato",
53
+ "area_ha": 2.5,
54
+ },
55
+ "max_tokens": 200,
56
+ "temperature": 0.5,
57
+ }
58
+ response = requests.post(f"{BASE_URL}/api/v1/llm/chat", json=payload)
59
+ print(f"Status: {response.status_code}")
60
+ data = response.json()
61
+ print(f"Model: {data.get('model')}")
62
+ print(f"Content: {data.get('content')}")
63
+ print(f"Latency (ms): {data.get('latency_ms')}")
64
+ print(f"Metadata: {json.dumps(data.get('metadata'), indent=2)}")
65
+ assert response.status_code == 200 or response.status_code == 500
66
+
67
+
68
+ def test_chat_with_history() -> None:
69
+ """Test chat with conversation history."""
70
+ print("\n=== Testing POST /api/v1/llm/chat (with history) ===")
71
+ payload = {
72
+ "content": "What about pepper crops?",
73
+ "conversation_history": [
74
+ {"role": "user", "content": "How much water should I apply weekly?"},
75
+ {"role": "assistant", "content": "For tomatoes, 25-40mm per week is typical."},
76
+ ],
77
+ "farm_context": {
78
+ "farm_name": "Johnson Farm",
79
+ "crop": "pepper",
80
+ "area_ha": 1.5,
81
+ },
82
+ "max_tokens": 200,
83
+ }
84
+ response = requests.post(f"{BASE_URL}/api/v1/llm/chat", json=payload)
85
+ print(f"Status: {response.status_code}")
86
+ data = response.json()
87
+ print(f"Content: {data.get('content')}")
88
+ print(f"Conversation history length: {data.get('metadata', {}).get('conversation_history_length')}")
89
+ assert response.status_code == 200 or response.status_code == 500
90
+
91
+
92
+ def test_validate_context_valid() -> None:
93
+ """Test context validation with valid data."""
94
+ print("\n=== Testing POST /api/v1/llm/validate-context (valid) ===")
95
+ payload = {
96
+ "farm_context": {
97
+ "farm_name": "Smith Farm",
98
+ "crop": "lettuce",
99
+ "area_ha": 0.5,
100
+ }
101
+ }
102
+ response = requests.post(f"{BASE_URL}/api/v1/llm/validate-context", json=payload)
103
+ print(f"Status: {response.status_code}")
104
+ data = response.json()
105
+ print(f"Valid: {data.get('valid')}")
106
+ print(f"Warnings: {data.get('warnings')}")
107
+ print(f"Errors: {data.get('errors')}")
108
+ assert response.status_code == 200
109
+ assert data.get('valid') is True
110
+
111
+
112
+ def test_validate_context_invalid_crop() -> None:
113
+ """Test context validation with invalid crop."""
114
+ print("\n=== Testing POST /api/v1/llm/validate-context (invalid crop) ===")
115
+ payload = {
116
+ "farm_context": {
117
+ "farm_name": "Smith Farm",
118
+ "crop": "invalid_crop",
119
+ "area_ha": 0.5,
120
+ }
121
+ }
122
+ response = requests.post(f"{BASE_URL}/api/v1/llm/validate-context", json=payload)
123
+ print(f"Status: {response.status_code}")
124
+ data = response.json()
125
+ print(f"Valid: {data.get('valid')}")
126
+ print(f"Warnings: {data.get('warnings')}")
127
+ assert response.status_code == 200
128
+ # Should still be valid (warnings don't fail validation)
129
+ assert data.get('valid') is True
130
+
131
+
132
+ def test_validate_context_invalid_area() -> None:
133
+ """Test context validation with invalid area type."""
134
+ print("\n=== Testing POST /api/v1/llm/validate-context (invalid area) ===")
135
+ payload = {
136
+ "farm_context": {
137
+ "farm_name": "Smith Farm",
138
+ "crop": "tomato",
139
+ "area_ha": "not a number",
140
+ }
141
+ }
142
+ response = requests.post(f"{BASE_URL}/api/v1/llm/validate-context", json=payload)
143
+ print(f"Status: {response.status_code}")
144
+ data = response.json()
145
+ print(f"Valid: {data.get('valid')}")
146
+ print(f"Errors: {data.get('errors')}")
147
+ assert response.status_code == 200
148
+ assert data.get('valid') is False
149
+
150
+
151
+ def test_batch_chat() -> None:
152
+ """Test batch chat endpoint."""
153
+ print("\n=== Testing POST /api/v1/llm/chat/batch ===")
154
+ payload = [
155
+ {
156
+ "content": "What is drip irrigation?",
157
+ "max_tokens": 100,
158
+ },
159
+ {
160
+ "content": "How do I install valves?",
161
+ "max_tokens": 100,
162
+ },
163
+ {
164
+ "content": "What is emitter spacing?",
165
+ "max_tokens": 100,
166
+ "farm_context": {
167
+ "crop": "tomato",
168
+ "area_ha": 1.0,
169
+ }
170
+ },
171
+ ]
172
+ response = requests.post(f"{BASE_URL}/api/v1/llm/chat/batch", json=payload)
173
+ print(f"Status: {response.status_code}")
174
+ if response.status_code == 200:
175
+ data = response.json()
176
+ print(f"Number of responses: {len(data)}")
177
+ for i, resp in enumerate(data):
178
+ print(f"\nResponse {i+1}:")
179
+ print(f" Content: {resp.get('content')[:100]}...")
180
+ print(f" Latency: {resp.get('latency_ms'):.1f}ms")
181
+
182
+
183
+ def test_chat_invalid_request() -> None:
184
+ """Test chat with invalid request (missing required field)."""
185
+ print("\n=== Testing POST /api/v1/llm/chat (invalid request) ===")
186
+ payload = {
187
+ "max_tokens": 100,
188
+ # Missing required 'content' field
189
+ }
190
+ response = requests.post(f"{BASE_URL}/api/v1/llm/chat", json=payload)
191
+ print(f"Status: {response.status_code}")
192
+ print(f"Response: {json.dumps(response.json(), indent=2)}")
193
+ assert response.status_code == 422
194
+
195
+
196
+ def test_chat_oversized_request() -> None:
197
+ """Test chat with oversized content."""
198
+ print("\n=== Testing POST /api/v1/llm/chat (oversized content) ===")
199
+ payload = {
200
+ "content": "x" * 3000, # Exceeds max_length of 2000
201
+ }
202
+ response = requests.post(f"{BASE_URL}/api/v1/llm/chat", json=payload)
203
+ print(f"Status: {response.status_code}")
204
+ print(f"Response: {json.dumps(response.json(), indent=2)}")
205
+ assert response.status_code == 422
206
+
207
+
208
+ if __name__ == "__main__":
209
+ print("=" * 70)
210
+ print("LLM Inference API Test Suite")
211
+ print("=" * 70)
212
+
213
+ try:
214
+ # Health check first
215
+ test_health()
216
+
217
+ # Basic tests
218
+ test_chat_simple()
219
+ test_chat_with_context()
220
+ test_chat_with_history()
221
+
222
+ # Context validation tests
223
+ test_validate_context_valid()
224
+ test_validate_context_invalid_crop()
225
+ test_validate_context_invalid_area()
226
+
227
+ # Batch test
228
+ test_batch_chat()
229
+
230
+ # Error handling tests
231
+ test_chat_invalid_request()
232
+ test_chat_oversized_request()
233
+
234
+ print("\n" + "=" * 70)
235
+ print("✅ All tests completed!")
236
+ print("=" * 70)
237
+
238
+ except requests.exceptions.ConnectionError:
239
+ print("\n❌ Error: Could not connect to server.")
240
+ print(" Make sure the server is running: python app.py")
241
+ except Exception as e:
242
+ print(f"\n❌ Unexpected error: {e}")
243
+ import traceback
244
+ traceback.print_exc()