The vendor adapter pattern allows you to integrate domain-specific agents and tools into Shannon without polluting the core codebase. This pattern maintains clean separation between:
Generic Shannon infrastructure (committed to open source)
Vendor-specific implementations (kept private or in separate repositories)
Zero Core Changes
No modifications to Shannon’s core codebase required
"""Vendor adapter for DataInsight Analytics API."""from typing import Any, Dict, List, Optionalclass DataInsightAdapter: """Transforms requests for DataInsight API conventions.""" def transform_body( self, body: Dict[str, Any], operation_id: str, prompt_params: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Transform request body for DataInsight API. Args: body: Original request body from LLM operation_id: OpenAPI operation ID prompt_params: Session context parameters (injected by orchestrator) Returns: Transformed body matching DataInsight API expectations """ if not isinstance(body, dict): return body # Inject session context (account_id, user_id from prompt_params) if prompt_params and isinstance(prompt_params, dict): if "account_id" in prompt_params and "account_id" not in body: body["account_id"] = prompt_params["account_id"] if "user_id" in prompt_params and "user_id" not in body: body["user_id"] = prompt_params["user_id"] # Operation-specific transformations if operation_id == "queryMetrics": body = self._transform_query_metrics(body) elif operation_id == "getDimensionValues": body = self._transform_dimension_values(body) return body def _transform_query_metrics(self, body: Dict) -> Dict: """Transform metric query requests.""" # Normalize metric names (support shorthand) metric_aliases = { "users": "di:unique_users", "sessions": "di:total_sessions", "pageviews": "di:page_views", "bounce_rate": "di:bounce_rate", } if isinstance(body.get("metrics"), list): body["metrics"] = [ metric_aliases.get(m, m) for m in body["metrics"] ] # Normalize time range format if "timeRange" in body and isinstance(body["timeRange"], dict): tr = body["timeRange"] # Ensure startTime/endTime (not start/end) if "start" in tr: tr["startTime"] = tr.pop("start") if "end" in tr: tr["endTime"] = tr.pop("end") # Convert sort to expected format if isinstance(body.get("sort"), dict): field = body["sort"].get("field") order = body["sort"].get("order", "DESC").upper() body["sort"] = {"field": field, "direction": order} return body def _transform_dimension_values(self, body: Dict) -> Dict: """Transform dimension value requests.""" dimension_aliases = { "country": "di:geo_country", "device": "di:device_type", "source": "di:traffic_source", } if "dimension" in body: body["dimension"] = dimension_aliases.get( body["dimension"], body["dimension"] ) return body
from typing import Optionaldef get_vendor_adapter(name: str): """Return a vendor adapter instance by name, or None if not available.""" if not name: return None try: if name.lower() == "datainsight": from .datainsight import DataInsightAdapter return DataInsightAdapter() # Add more vendors here # elif name.lower() == "othervendor": # from .othervendor import OtherVendorAdapter # return OtherVendorAdapter() except Exception: return None return None
"""DataInsight Analytics Agent role preset."""ANALYTICS_AGENT_PRESET = { "name": "datainsight_analytics", "system_prompt": """You are a specialized data analytics agent with access to DataInsight Analytics API.Your mission: Provide actionable insights from web analytics data.## Available Tools- queryMetrics: Retrieve metrics like users, sessions, pageviews, bounce rate- getDimensionValues: Get dimension values (countries, devices, traffic sources)## Output FormatAlways structure your response as:1. **dataResult** block (JSON) - for visualization2. **Summary** section - key findings3. **Insights** section - actionable recommendations## Best Practices- Always include time range in queries- Use metric aliases: "users", "sessions", "pageviews" (auto-converted)- Request relevant dimensions for context- Provide comparative analysis when possible- Highlight anomalies and trendsRemember: Your goal is to help users understand their data and make better decisions.""", "allowed_tools": [ "queryMetrics", "getDimensionValues", ], "temperature": 0.7, "response_format": None,}
Note: allowed_tools semantics for /agent/query:
Omit/null → role presets may enable tools
[] → tools disabled
["name", …] → only these tools are available (names must match registered tools)
Register in python/llm-service/llm_service/roles/presets.py:
Copy
# At the end of _load_presets() function:try: from .datainsight.analytics_agent import ANALYTICS_AGENT_PRESET _PRESETS["datainsight_analytics"] = ANALYTICS_AGENT_PRESETexcept ImportError: pass # Graceful fallback if vendor module not available
5
Add Environment Variables
Add to .env:
Copy
# DataInsight ConfigurationSHANNON_CONFIG_PATH=config/overlays/shannon.datainsight.yamlDATAINSIGHT_API_TOKEN=your_bearer_token_hereDATAINSIGHT_USER_ID=your_user_id_here# Domain allowlist (dev: use *, prod: specific domains)OPENAPI_ALLOWED_DOMAINS=api.datainsight.com
6
Test Integration
Rebuild and test:
Copy
# Rebuild servicesdocker compose -f deploy/compose/docker-compose.yml build --no-cache llm-service orchestratordocker compose -f deploy/compose/docker-compose.yml up -d# Wait for healthsleep 10# Test via gRPCSESSION_ID="test-$(date +%s)"grpcurl -plaintext -d '{ "metadata": { "user_id": "test-user", "session_id": "'$SESSION_ID'" }, "query": "Show me user growth trends for the past 30 days", "context": { "role": "datainsight_analytics", "prompt_params": { "account_id": "acct_12345", "user_id": "user_67890" } }}' localhost:50052 shannon.orchestrator.OrchestratorService/SubmitTask
def transform_body(self, body, operation_id, prompt_params): # Generic field normalization if "start_date" in body and "startTime" not in body: body["startTime"] = body.pop("start_date") return body
❌ Bad: Business logic in adapter
Copy
def transform_body(self, body, operation_id, prompt_params): # Don't do complex business logic here if body["revenue"] > 1000000: body["alert"] = "high_revenue" # Belongs in application layer
Use Graceful Fallback
Copy
try: from .myvendor import MyVendorAdapter return MyVendorAdapter()except ImportError: pass # Shannon works without vendor moduleexcept Exception as e: logger.warning(f"Failed to load vendor adapter: {e}")return None
Document Transformations
Copy
def transform_body(self, body, operation_id, prompt_params): """ Transform body for MyVendor API. Transformations: - Metric names: "users" → "mv:unique_users" - Time range: {start, end} → {startTime, endTime} - Sort format: {field, order} → {column, direction} - Inject tenant_id from prompt_params """
Keep Secrets in Environment
✅ Good:
Copy
auth_config: token: "${MYVENDOR_TOKEN}"
❌ Bad:
Copy
auth_config: token: "sk-1234567890abcdef" # Never hardcode!
def transform_body(self, body, operation_id, prompt_params): if not isinstance(body, dict): return body # Don't transform non-dict # Validate required fields if operation_id == "queryData" and "metrics" not in body: return body # Let API return validation error
Symptom: Logs show “Vendor adapter ” applied” (empty string)Fix:
Copy
# Ensure vendor name is set in config overlayauth_config: vendor: myvendor # Must match adapter name
Imports Failing
Symptom: ImportError: No module named 'myvendor'Fix:
Copy
# Use try/except in __init__.pytry: from .myvendor import MyVendorAdapter return MyVendorAdapter()except ImportError as e: logger.warning(f"Vendor adapter not available: {e}") return None
Transformations Not Applied
Symptom: API receives original body, not transformedDebug: