feat: add multi-model support for per-analysis LLM selection

Allow users to choose the LLM model on a per-analysis basis. The
model field is optional in both single and batch analysis requests,
defaulting to the server-configured MODEL env var. The model used
is recorded in the analysis result and database.

- Add model parameter to LLMAnalyzer.analyze_patent_content and
  analyze_patent_portfolio
- Add model field to CompanyAnalysisResult and API response
- Add model field to BatchAnalysisRequest
- Add GET /models endpoint listing supported models and the default
- Store model in llm_messages metadata for attribution

Closes leeworks-agents/SPARC#37

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent-company
2026-03-26 10:28:25 +00:00
parent 55c131cb32
commit 04f4d36307
3 changed files with 60 additions and 12 deletions
+41
View File
@@ -41,6 +41,7 @@ class CompanyAnalysisResponse(BaseModel):
patent_count: int patent_count: int
success: bool success: bool
error: str | None = None error: str | None = None
model: str | None = None
timestamp: datetime timestamp: datetime
@@ -54,6 +55,15 @@ class BatchAnalysisResponse(BaseModel):
timestamp: datetime timestamp: datetime
class CompanyAnalysisRequest(BaseModel):
"""Request model for single company analysis with optional model selection."""
model: str | None = Field(
default=None,
description="LLM model to use (e.g. 'anthropic/claude-3.5-sonnet', 'openai/gpt-4o'). Defaults to server config.",
)
class BatchAnalysisRequest(BaseModel): class BatchAnalysisRequest(BaseModel):
"""Request model for batch company analysis.""" """Request model for batch company analysis."""
@@ -63,6 +73,10 @@ class BatchAnalysisRequest(BaseModel):
max_workers: int = Field( max_workers: int = Field(
default=3, ge=1, le=5, description="Max concurrent analyses" default=3, ge=1, le=5, description="Max concurrent analyses"
) )
model: str | None = Field(
default=None,
description="LLM model to use for all analyses in this batch. Defaults to server config.",
)
class JobStatus(BaseModel): class JobStatus(BaseModel):
@@ -133,6 +147,7 @@ def _convert_result(result: CompanyAnalysisResult) -> CompanyAnalysisResponse:
patent_count=result.patent_count, patent_count=result.patent_count,
success=result.success, success=result.success,
error=result.error, error=result.error,
model=result.model,
timestamp=result.timestamp, timestamp=result.timestamp,
) )
@@ -389,6 +404,32 @@ async def get_analytics(
) )
# ============== Model Selection Endpoints ==============
# Supported models via OpenRouter
SUPPORTED_MODELS = [
{"id": "anthropic/claude-3.5-sonnet", "name": "Claude 3.5 Sonnet", "provider": "Anthropic"},
{"id": "openai/gpt-4o", "name": "GPT-4o", "provider": "OpenAI"},
{"id": "openai/gpt-4o-mini", "name": "GPT-4o Mini", "provider": "OpenAI"},
{"id": "google/gemini-pro-1.5", "name": "Gemini Pro 1.5", "provider": "Google"},
{"id": "meta-llama/llama-3.1-70b-instruct", "name": "Llama 3.1 70B", "provider": "Meta"},
]
@app.get("/models", tags=["System"])
async def list_models():
"""List supported LLM models for analysis.
Returns the available models that can be passed as the `model` field
in analysis requests. The default model is determined by the `MODEL`
environment variable on the server.
"""
return {
"models": SUPPORTED_MODELS,
"default": config.model,
}
# ============== System Endpoints ============== # ============== System Endpoints ==============
+17 -11
View File
@@ -40,12 +40,13 @@ class LLMAnalyzer:
else: else:
self.client = None self.client = None
def analyze_patent_content(self, patent_content: str, company_name: str) -> str: def analyze_patent_content(self, patent_content: str, company_name: str, model: str | None = None) -> str:
"""Analyze patent content to estimate company innovation and performance. """Analyze patent content to estimate company innovation and performance.
Args: Args:
patent_content: Minimized patent text (abstract, claims, summary) patent_content: Minimized patent text (abstract, claims, summary)
company_name: Name of the company for context company_name: Name of the company for context
model: Optional model override (e.g. "openai/gpt-4o"). Defaults to config.
Returns: Returns:
Analysis text describing innovation quality and potential impact Analysis text describing innovation quality and potential impact
@@ -63,6 +64,8 @@ Patent Content:
Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals about the company's technical direction and competitive advantage.""" Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals about the company's technical direction and competitive advantage."""
effective_model = model or self.model
if self.test_mode: if self.test_mode:
logger.debug("TEST MODE - Prompt that would be sent to LLM:\n%s", prompt) logger.debug("TEST MODE - Prompt that would be sent to LLM:\n%s", prompt)
return "[TEST MODE - No API call made]" return "[TEST MODE - No API call made]"
@@ -81,7 +84,7 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals
response=cached["response"], response=cached["response"],
company_name=company_name, company_name=company_name,
analysis_type="single_patent", analysis_type="single_patent",
model=self.model, model=effective_model,
metadata={ metadata={
"patent_content_length": len(patent_content), "patent_content_length": len(patent_content),
"cache_hit": True, "cache_hit": True,
@@ -94,7 +97,7 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals
# Call API if no cache hit and client is available # Call API if no cache hit and client is available
if self.client: if self.client:
response = self.client.chat.completions.create( response = self.client.chat.completions.create(
model=self.model, model=effective_model,
max_tokens=1024, max_tokens=1024,
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
) )
@@ -106,7 +109,7 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals
response=response_text, response=response_text,
company_name=company_name, company_name=company_name,
analysis_type="single_patent", analysis_type="single_patent",
model=self.model, model=effective_model,
metadata={"patent_content_length": len(patent_content)}, metadata={"patent_content_length": len(patent_content)},
token_usage={ token_usage={
"prompt_tokens": response.usage.prompt_tokens, "prompt_tokens": response.usage.prompt_tokens,
@@ -124,13 +127,13 @@ Provide a concise analysis (2-3 paragraphs) focusing on what this patent reveals
response=placeholder, response=placeholder,
company_name=company_name, company_name=company_name,
analysis_type="single_patent", analysis_type="single_patent",
model=self.model, model=effective_model,
metadata={"patent_content_length": len(patent_content), "pending": True} metadata={"patent_content_length": len(patent_content), "pending": True}
) )
return placeholder return placeholder
def analyze_patent_portfolio( def analyze_patent_portfolio(
self, patents_data: list[Dict[str, str]], company_name: str self, patents_data: list[Dict[str, str]], company_name: str, model: str | None = None
) -> str: ) -> str:
"""Analyze multiple patents to estimate overall company performance. """Analyze multiple patents to estimate overall company performance.
@@ -165,13 +168,16 @@ Patent Portfolio:
Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the company's innovation strength and performance outlook.""" Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the company's innovation strength and performance outlook."""
effective_model = model or self.model
if self.test_mode: if self.test_mode:
logger.debug("TEST MODE - Portfolio prompt:\n%s", prompt) logger.debug("TEST MODE - Portfolio prompt:\n%s", prompt)
return "[TEST MODE]" return "[TEST MODE]"
metadata = { metadata = {
"patent_count": len(patents_data), "patent_count": len(patents_data),
"patent_ids": [p['patent_id'] for p in patents_data] "patent_ids": [p['patent_id'] for p in patents_data],
"model": effective_model,
} }
# Check cache first # Check cache first
@@ -188,7 +194,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co
response=cached["response"], response=cached["response"],
company_name=company_name, company_name=company_name,
analysis_type="portfolio", analysis_type="portfolio",
model=self.model, model=effective_model,
metadata={ metadata={
**metadata, **metadata,
"cache_hit": True, "cache_hit": True,
@@ -202,7 +208,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co
if self.client: if self.client:
try: try:
response = self.client.chat.completions.create( response = self.client.chat.completions.create(
model=self.model, model=effective_model,
max_tokens=2048, max_tokens=2048,
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
) )
@@ -215,7 +221,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co
response=response_text, response=response_text,
company_name=company_name, company_name=company_name,
analysis_type="portfolio", analysis_type="portfolio",
model=self.model, model=effective_model,
metadata=metadata, metadata=metadata,
token_usage={ token_usage={
"prompt_tokens": response.usage.prompt_tokens, "prompt_tokens": response.usage.prompt_tokens,
@@ -235,7 +241,7 @@ Provide a comprehensive analysis (4-5 paragraphs) with a final verdict on the co
response=placeholder, response=placeholder,
company_name=company_name, company_name=company_name,
analysis_type="portfolio", analysis_type="portfolio",
model=self.model, model=effective_model,
metadata={**metadata, "pending": True} metadata={**metadata, "pending": True}
) )
return placeholder return placeholder
+1
View File
@@ -24,6 +24,7 @@ class CompanyAnalysisResult:
patent_count: int patent_count: int
success: bool success: bool
error: str | None = None error: str | None = None
model: str | None = None
timestamp: datetime = field(default_factory=datetime.now) timestamp: datetime = field(default_factory=datetime.now)