feat: Major UI/UX improvements and production readiness
CI / backend-test (push) Successful in 4m9s
CI / frontend-test (push) Failing after 3m48s
CI / lint-python (push) Successful in 1m41s
CI / secret-scanning (push) Successful in 1m20s
CI / dependency-scan (push) Successful in 10m50s
CI / workflow-summary (push) Successful in 1m11s

## Features Added

### Document Reference System
- Implemented numbered document references (@1, @2, etc.) with autocomplete dropdown
- Added fuzzy filename matching for @filename references
- Document filtering now prioritizes numeric refs > filename refs > all documents
- Autocomplete dropdown appears when typing @ with keyboard navigation (Up/Down, Enter/Tab, Escape)
- Document numbers displayed in UI for easy reference

### Conversation Management
- Added conversation rename functionality with inline editing
- Implemented conversation search (by title and content)
- Search box always visible, even when no conversations exist
- Export reports now replace @N references with actual filenames

### UI/UX Improvements
- Removed debug toggle button
- Improved text contrast in dark mode (better visibility)
- Made input textarea expand to full available width
- Fixed file text color for better readability
- Enhanced document display with numbered badges

### Configuration & Timeouts
- Made HTTP client timeouts configurable (connect, write, pool)
- Added .env.example with all configuration options
- Updated timeout documentation

### Developer Experience
- Added `make test-setup` target for automated test conversation creation
- Test setup script supports TEST_MESSAGE and TEST_DOCS env vars
- Improved Makefile with dev and test-setup targets

### Documentation
- Updated ARCHITECTURE.md with all new features
- Created comprehensive deployment documentation
- Added GPU VM setup guides
- Removed unnecessary markdown files (CLAUDE.md, CONTRIBUTING.md, header.jpg)
- Organized documentation in docs/ directory

### GPU VM / Ollama (Stability + GPU Offload)
- Updated GPU VM docs to reflect the working systemd environment for remote Ollama
- Standardized remote Ollama port to 11434 (and added /v1/models verification)
- Documented required env for GPU offload on this VM:
  - `OLLAMA_MODELS=/mnt/data/ollama`, `HOME=/mnt/data/ollama/home`
  - `OLLAMA_LLM_LIBRARY=cuda_v12` (not `cuda`)
  - `LD_LIBRARY_PATH=/usr/local/lib/ollama:/usr/local/lib/ollama/cuda_v12`

## Technical Changes

### Backend
- Enhanced `docs_context.py` with reference parsing (numeric and filename)
- Added `update_conversation_title` to storage.py
- New endpoints: PATCH /api/conversations/{id}/title, GET /api/conversations/search
- Improved report generation with filename substitution

### Frontend
- Removed debugMode state and related code
- Added autocomplete dropdown component
- Implemented search functionality in Sidebar
- Enhanced ChatInterface with autocomplete and improved textarea sizing
- Updated CSS for better contrast and responsive design

## Files Changed
- Backend: config.py, council.py, docs_context.py, main.py, storage.py
- Frontend: App.jsx, ChatInterface.jsx, Sidebar.jsx, and related CSS files
- Documentation: README.md, ARCHITECTURE.md, new docs/ directory
- Configuration: .env.example, Makefile
- Scripts: scripts/test_setup.py

## Breaking Changes
None - all changes are backward compatible

## Testing
- All existing tests pass
- New test-setup script validates conversation creation workflow
- Manual testing of autocomplete, search, and rename features
This commit is contained in:
ira
2025-12-28 18:15:02 -05:00
commit 3546c04348
79 changed files with 13531 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""LLM Council backend package."""
+109
View File
@@ -0,0 +1,109 @@
"""Configuration for the LLM Council."""
import os
from dotenv import load_dotenv
load_dotenv()
# Helpers
def _parse_int_env(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None or raw.strip() == "":
return default
try:
return int(raw.strip())
except ValueError:
return default
def _parse_float_env(name: str, default: float) -> float:
raw = os.getenv(name)
if raw is None or raw.strip() == "":
return default
try:
return float(raw.strip())
except ValueError:
return default
def _parse_list_env(name: str) -> list[str] | None:
"""
Parses a list from an env var.
Supported formats:
- Comma-separated: "a,b,c"
- Newline-separated: "a\\nb\\nc"
"""
raw = os.getenv(name)
if raw is None:
return None
raw = raw.strip()
if raw == "":
return []
# Allow either commas or newlines.
parts = []
for chunk in raw.replace("\r\n", "\n").split("\n"):
parts.extend(chunk.split(","))
return [p.strip() for p in parts if p.strip()]
# Council members - list of model identifiers (Ollama model names)
# Can be overridden via env var COUNCIL_MODELS (comma or newline separated).
_DEFAULT_COUNCIL_MODELS = [
"llama3.2:3b",
"qwen2.5:3b",
"gemma2:2b",
]
COUNCIL_MODELS = _parse_list_env("COUNCIL_MODELS") or _DEFAULT_COUNCIL_MODELS
# Chairman model - synthesizes final response
CHAIRMAN_MODEL = os.getenv("CHAIRMAN_MODEL") or "llama3.2:3b"
# Maximum tokens per request
# Default: 2048 tokens (reasonable for most responses)
# Increase if you need longer responses
MAX_TOKENS = _parse_int_env("MAX_TOKENS", 2048)
# Request timeout configuration (in seconds)
# Default timeout for general LLM queries (Stage 1: council responses)
# Used by llm_client.py and passed to openai_compat.query_model()
LLM_TIMEOUT_SECONDS = _parse_float_env("LLM_TIMEOUT_SECONDS", 120.0)
# Timeout for chairman synthesis (may need longer for complex responses)
CHAIRMAN_TIMEOUT_SECONDS = _parse_float_env("CHAIRMAN_TIMEOUT_SECONDS", 180.0)
# Timeout for title generation (short responses)
TITLE_GENERATION_TIMEOUT_SECONDS = _parse_float_env("TITLE_GENERATION_TIMEOUT_SECONDS", 120.0)
# OpenAI-compatible provider tuning (Ollama / vLLM / TGI)
# If USE_LOCAL_OLLAMA=true, automatically set base URL to localhost:11434 (convenience flag)
if os.getenv("USE_LOCAL_OLLAMA", "").strip().lower() in ("true", "1", "yes"):
_openai_compat_base_url = "http://localhost:11434"
else:
_openai_compat_base_url = os.getenv("OPENAI_COMPAT_BASE_URL")
OPENAI_COMPAT_BASE_URL = _openai_compat_base_url
# HTTP client timeout (fallback when timeout not explicitly passed to openai_compat functions)
# Used by: list_models() and as fallback in query_model() if called directly without timeout
# Should be >= LLM_TIMEOUT_SECONDS for safety, but list_models() is fast so can be lower
OPENAI_COMPAT_TIMEOUT_SECONDS = _parse_float_env("OPENAI_COMPAT_TIMEOUT_SECONDS", 300.0)
# HTTP client connection timeout (time to establish connection)
OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS = _parse_float_env("OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS", 10.0)
# HTTP client write timeout (time to send request)
OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS = _parse_float_env("OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS", 10.0)
# HTTP client pool timeout (time to get connection from pool)
OPENAI_COMPAT_POOL_TIMEOUT_SECONDS = _parse_float_env("OPENAI_COMPAT_POOL_TIMEOUT_SECONDS", 10.0)
# Number of retries for failed requests (retryable HTTP errors: 408, 409, 425, 429, 500, 502, 503, 504)
OPENAI_COMPAT_RETRIES = _parse_int_env("OPENAI_COMPAT_RETRIES", 2)
# Exponential backoff base delay between retries (seconds) - actual delay is backoff * (2^attempt)
OPENAI_COMPAT_RETRY_BACKOFF_SECONDS = _parse_float_env("OPENAI_COMPAT_RETRY_BACKOFF_SECONDS", 0.5)
# Debug mode - show debug logs in console (set DEBUG=true in .env)
DEBUG = os.getenv("DEBUG", "").strip().lower() in ("true", "1", "yes")
# Markdown uploads (per-conversation)
DOCS_DIR = os.getenv("DOCS_DIR") or "data/docs"
MAX_DOC_BYTES = _parse_int_env("MAX_DOC_BYTES", 1_000_000) # 1MB
MAX_DOC_PREVIEW_CHARS = _parse_int_env("MAX_DOC_PREVIEW_CHARS", 20_000)
# Data directory for conversation storage
DATA_DIR = "data/conversations"
+537
View File
@@ -0,0 +1,537 @@
"""3-stage LLM Council orchestration."""
import time
from typing import List, Dict, Any, Tuple, Optional
from .llm_client import query_models_parallel, query_model
from .config import COUNCIL_MODELS, CHAIRMAN_MODEL, CHAIRMAN_TIMEOUT_SECONDS, TITLE_GENERATION_TIMEOUT_SECONDS
def _format_docs_context(docs_text: Optional[str]) -> str:
if not docs_text or not docs_text.strip():
return ""
return (
"\n\nREFERENCE DOCUMENTS (user-provided markdown):\n"
"Use these as additional context if relevant. Quote sparingly and cite sections when helpful.\n"
f"{docs_text.strip()}\n"
)
async def stage1_collect_responses(user_query: str, docs_text: Optional[str] = None) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""
Stage 1: Collect individual responses from all council models.
Args:
user_query: The user's question
Returns:
Tuple of (results list, metadata dict with timing info)
"""
start_time = time.time()
prompt = f"{user_query}{_format_docs_context(docs_text)}"
messages = [{"role": "user", "content": prompt}]
# Query all models in parallel
responses = await query_models_parallel(COUNCIL_MODELS, messages)
duration = time.time() - start_time
# Format results
stage1_results = []
successful_models = []
failed_models = []
for model, response in responses.items():
if response is not None: # Only include successful responses
stage1_results.append({
"model": model,
"response": response.get('content', '')
})
successful_models.append(model)
else:
failed_models.append(model)
metadata = {
"duration_seconds": round(duration, 2),
"successful_models": successful_models,
"failed_models": failed_models,
"total_models": len(COUNCIL_MODELS)
}
return stage1_results, metadata
async def stage1_collect_responses_streaming(
user_query: str,
docs_text: Optional[str] = None,
on_response = None
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
"""
Stage 1: Collect individual responses from all council models, streaming as they complete.
Args:
user_query: The user's question
docs_text: Optional document context
on_response: Optional callback(model, response_dict) called as each response completes
Returns:
Tuple of (results list, metadata dict with timing info)
"""
import asyncio
from .llm_client import query_model, LLM_TIMEOUT_SECONDS, MAX_TOKENS
start_time = time.time()
prompt = f"{user_query}{_format_docs_context(docs_text)}"
messages = [{"role": "user", "content": prompt}]
# Query all models in parallel, but yield results as they complete
async def query_and_notify(model: str):
response = await query_model(model, messages, timeout=LLM_TIMEOUT_SECONDS, max_tokens_override=MAX_TOKENS)
if on_response and response is not None:
result = {"model": model, "response": response.get('content', '')}
await on_response(model, result)
return model, response
tasks = [query_and_notify(model) for model in COUNCIL_MODELS]
responses_dict = {}
# Use as_completed to process results as they finish
for coro in asyncio.as_completed(tasks):
model, response = await coro
responses_dict[model] = response
duration = time.time() - start_time
# Format results
stage1_results = []
successful_models = []
failed_models = []
for model, response in responses_dict.items():
if response is not None: # Only include successful responses
stage1_results.append({
"model": model,
"response": response.get('content', '')
})
successful_models.append(model)
else:
failed_models.append(model)
metadata = {
"duration_seconds": round(duration, 2),
"successful_models": successful_models,
"failed_models": failed_models,
"total_models": len(COUNCIL_MODELS)
}
return stage1_results, metadata
async def stage2_collect_rankings(
user_query: str,
stage1_results: List[Dict[str, Any]],
docs_text: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, str], Dict[str, Any]]:
"""
Stage 2: Each model ranks the anonymized responses.
Args:
user_query: The original user query
stage1_results: Results from Stage 1
Returns:
Tuple of (rankings list, label_to_model mapping, metadata dict with timing)
"""
start_time = time.time()
# Handle empty stage1_results
if not stage1_results:
return [], {}, {"duration_seconds": 0.0, "successful_models": [], "failed_models": [], "total_models": len(COUNCIL_MODELS)}
# Create anonymized labels for responses (Response A, Response B, etc.)
labels = [chr(65 + i) for i in range(len(stage1_results))] # A, B, C, ...
# Create mapping from label to model name
label_to_model = {
f"Response {label}": result['model']
for label, result in zip(labels, stage1_results)
}
# Build the ranking prompt
responses_text = "\n\n".join([
f"Response {label}:\n{result['response']}"
for label, result in zip(labels, stage1_results)
])
ranking_prompt = f"""You are evaluating different responses to the following question:
Question: {user_query}
{_format_docs_context(docs_text)}
Here are the responses from different models (anonymized):
{responses_text}
Your task:
1. First, evaluate each response individually. For each response, explain what it does well and what it does poorly.
2. Then, at the very end of your response, provide a final ranking.
IMPORTANT: Your final ranking MUST be formatted EXACTLY as follows:
- Start with the line "FINAL RANKING:" (all caps, with colon)
- Then list the responses from best to worst as a numbered list
- Each line should be: number, period, space, then ONLY the response label (e.g., "1. Response A")
- Do not add any other text or explanations in the ranking section
Example of the correct format for your ENTIRE response:
Response A provides good detail on X but misses Y...
Response B is accurate but lacks depth on Z...
Response C offers the most comprehensive answer...
FINAL RANKING:
1. Response C
2. Response A
3. Response B
Now provide your evaluation and ranking:"""
messages = [{"role": "user", "content": ranking_prompt}]
# Get rankings from all council models in parallel
responses = await query_models_parallel(COUNCIL_MODELS, messages)
duration = time.time() - start_time
# Format results
stage2_results = []
successful_models = []
failed_models = []
for model, response in responses.items():
if response is not None:
full_text = response.get('content', '')
parsed = parse_ranking_from_text(full_text)
stage2_results.append({
"model": model,
"ranking": full_text,
"parsed_ranking": parsed
})
successful_models.append(model)
else:
failed_models.append(model)
metadata = {
"duration_seconds": round(duration, 2),
"successful_models": successful_models,
"failed_models": failed_models,
"total_models": len(COUNCIL_MODELS)
}
return stage2_results, label_to_model, metadata
async def stage3_synthesize_final(
user_query: str,
stage1_results: List[Dict[str, Any]],
stage2_results: List[Dict[str, Any]],
docs_text: Optional[str] = None,
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""
Stage 3: Chairman synthesizes final response.
Args:
user_query: The original user query
stage1_results: Individual model responses from Stage 1
stage2_results: Rankings from Stage 2
Returns:
Tuple of (result dict with 'model' and 'response' keys, metadata dict with timing)
"""
start_time = time.time()
# Handle empty inputs
if not stage1_results:
duration = time.time() - start_time
return {
"model": CHAIRMAN_MODEL,
"response": "Error: Cannot synthesize final answer - no responses from Stage 1."
}, {
"duration_seconds": round(duration, 2),
"model": CHAIRMAN_MODEL,
"success": False
}
# Build comprehensive context for chairman
# Truncate very long responses to avoid exceeding token/context limits
# More aggressive truncation to keep total prompt under ~2000 tokens (~8000 chars)
MAX_RESPONSE_LENGTH = 2000 # Characters per response
MAX_RANKING_LENGTH = 1000 # Characters per ranking
MAX_DOCS_LENGTH = 2000 # Max characters for docs context
MAX_TOTAL_PROMPT_LENGTH = 8000 # Max total prompt length (safety limit)
def truncate_text(text: str, max_length: int) -> str:
"""Truncate text to max_length, adding ellipsis if truncated."""
if len(text) <= max_length:
return text
return text[:max_length-3] + "..."
# Truncate docs_text if provided
truncated_docs_text = docs_text
if docs_text and len(docs_text) > MAX_DOCS_LENGTH:
truncated_docs_text = truncate_text(docs_text, MAX_DOCS_LENGTH)
stage1_text = "\n\n".join([
f"Model: {result['model']}\nResponse: {truncate_text(result['response'], MAX_RESPONSE_LENGTH)}"
for result in stage1_results
])
stage2_text = "\n\n".join([
f"Model: {result['model']}\nRanking: {truncate_text(result['ranking'], MAX_RANKING_LENGTH)}"
for result in stage2_results
]) if stage2_results else "No rankings available from Stage 2."
chairman_prompt = f"""You are the Chairman of an LLM Council. Multiple AI models have provided responses to a user's question, and then ranked each other's responses.
Original Question: {user_query}
{_format_docs_context(truncated_docs_text)}
STAGE 1 - Individual Responses:
{stage1_text}
STAGE 2 - Peer Rankings:
{stage2_text}
Your task as Chairman is to synthesize all of this information into a single, comprehensive, accurate answer to the user's original question. Consider:
- The individual responses and their insights
- The peer rankings and what they reveal about response quality
- Any patterns of agreement or disagreement
Provide a clear, well-reasoned final answer that represents the council's collective wisdom:"""
# Apply final safety truncation if prompt is still too long
if len(chairman_prompt) > MAX_TOTAL_PROMPT_LENGTH:
# Truncate the prompt itself if it exceeds the limit
chairman_prompt = chairman_prompt[:MAX_TOTAL_PROMPT_LENGTH - 100] + "\n\n[Content truncated due to length limits...]\n\nProvide a clear, well-reasoned final answer:"
messages = [{"role": "user", "content": chairman_prompt}]
# Query the chairman model
# Note: For very long prompts, we might need to truncate or summarize
# For now, we'll try with the full prompt and handle errors gracefully
# Use the default max_tokens (2048) to stay within credit limits
# If you have more credits, you can increase MAX_TOKENS in config.py
response = await query_model(CHAIRMAN_MODEL, messages, timeout=CHAIRMAN_TIMEOUT_SECONDS)
duration = time.time() - start_time
if response is None:
# Try to get more specific error info - check if prompt might be too long
prompt_length = len(chairman_prompt)
estimated_tokens = prompt_length // 4 # Rough estimate: ~4 chars per token
error_msg = (
"Error: Unable to generate final synthesis.\n\n"
"The chairman model failed to respond. Possible causes:\n"
"- Model '{}' not available on the server\n"
"- Server not running or unreachable\n"
"- Network/API errors\n"
"- Prompt too long (estimated ~{} tokens)\n"
"- Server timeout or overloaded\n\n"
"Check the backend terminal logs for the exact error message."
).format(CHAIRMAN_MODEL, estimated_tokens)
return {
"model": CHAIRMAN_MODEL,
"response": error_msg
}, {
"duration_seconds": round(duration, 2),
"model": CHAIRMAN_MODEL,
"success": False
}
return {
"model": CHAIRMAN_MODEL,
"response": response.get('content', '')
}, {
"duration_seconds": round(duration, 2),
"model": CHAIRMAN_MODEL,
"success": True
}
def parse_ranking_from_text(ranking_text: str) -> List[str]:
"""
Parse the FINAL RANKING section from the model's response.
Args:
ranking_text: The full text response from the model
Returns:
List of response labels in ranked order
"""
import re
# Look for "FINAL RANKING:" section
if "FINAL RANKING:" in ranking_text:
# Extract everything after "FINAL RANKING:"
parts = ranking_text.split("FINAL RANKING:")
if len(parts) >= 2:
ranking_section = parts[1]
# Try to extract numbered list format (e.g., "1. Response A")
# This pattern looks for: number, period, optional space, "Response X"
numbered_matches = re.findall(r'\d+\.\s*Response [A-Z]', ranking_section)
if numbered_matches:
# Extract just the "Response X" part
return [re.search(r'Response [A-Z]', m).group() for m in numbered_matches]
# Fallback: Extract all "Response X" patterns in order
matches = re.findall(r'Response [A-Z]', ranking_section)
return matches
# Fallback: try to find any "Response X" patterns in order
matches = re.findall(r'Response [A-Z]', ranking_text)
return matches
def calculate_aggregate_rankings(
stage2_results: List[Dict[str, Any]],
label_to_model: Dict[str, str]
) -> List[Dict[str, Any]]:
"""
Calculate aggregate rankings across all models.
Args:
stage2_results: Rankings from each model
label_to_model: Mapping from anonymous labels to model names
Returns:
List of dicts with model name and average rank, sorted best to worst
"""
from collections import defaultdict
# Track positions for each model
model_positions = defaultdict(list)
for ranking in stage2_results:
ranking_text = ranking['ranking']
# Parse the ranking from the structured format
parsed_ranking = parse_ranking_from_text(ranking_text)
for position, label in enumerate(parsed_ranking, start=1):
if label in label_to_model:
model_name = label_to_model[label]
model_positions[model_name].append(position)
# Calculate average position for each model
aggregate = []
for model, positions in model_positions.items():
if positions:
avg_rank = sum(positions) / len(positions)
aggregate.append({
"model": model,
"average_rank": round(avg_rank, 2),
"rankings_count": len(positions)
})
# Sort by average rank (lower is better)
aggregate.sort(key=lambda x: x['average_rank'])
return aggregate
async def generate_conversation_title(user_query: str) -> str:
"""
Generate a short title for a conversation based on the first user message.
Args:
user_query: The first user message
Returns:
A short title (3-5 words)
"""
title_prompt = f"""Generate a very short title (3-5 words maximum) that summarizes the following question.
The title should be concise and descriptive. Do not use quotes or punctuation in the title.
Question: {user_query}
Title:"""
messages = [{"role": "user", "content": title_prompt}]
# Use chairman model for title generation
# Use configurable timeout (may need longer for local models which load on first request)
response = await query_model(CHAIRMAN_MODEL, messages, timeout=TITLE_GENERATION_TIMEOUT_SECONDS, max_tokens_override=50)
if response is None:
# Fallback to a generic title
return "New Conversation"
title = response.get('content', 'New Conversation').strip()
# Clean up the title - remove quotes, limit length
title = title.strip('"\'')
# Truncate if too long
if len(title) > 50:
title = title[:47] + "..."
return title
async def run_full_council(user_query: str, docs_text: Optional[str] = None) -> Tuple[List, List, Dict, Dict]:
"""
Run the complete 3-stage council process.
Args:
user_query: The user's question
Returns:
Tuple of (stage1_results, stage2_results, stage3_result, metadata)
"""
total_start_time = time.time()
# Stage 1: Collect individual responses
stage1_results, stage1_metadata = await stage1_collect_responses(user_query, docs_text=docs_text)
# If no models responded successfully, return error with helpful message
if not stage1_results:
error_msg = (
"All models failed to respond. This could be due to:\n"
"- Server not running or unreachable\n"
"- Model names not available on the server\n"
"- Network/API errors\n"
"- Server timeout or overloaded\n"
"- Invalid OPENAI_COMPAT_BASE_URL configuration\n\n"
"Check the backend logs for detailed error messages."
)
total_duration = time.time() - total_start_time
return [], [], {
"model": "error",
"response": error_msg
}, {
"label_to_model": {},
"aggregate_rankings": {},
"stage1_metadata": stage1_metadata,
"stage2_metadata": {},
"stage3_metadata": {},
"total_duration_seconds": round(total_duration, 2)
}
# Stage 2: Collect rankings
stage2_results, label_to_model, stage2_metadata = await stage2_collect_rankings(user_query, stage1_results, docs_text=docs_text)
# Calculate aggregate rankings
aggregate_rankings = calculate_aggregate_rankings(stage2_results, label_to_model)
# Stage 3: Synthesize final answer
stage3_result, stage3_metadata = await stage3_synthesize_final(
user_query,
stage1_results,
stage2_results,
docs_text=docs_text,
)
total_duration = time.time() - total_start_time
# Prepare metadata
metadata = {
"label_to_model": label_to_model,
"aggregate_rankings": aggregate_rankings,
"stage1_metadata": stage1_metadata,
"stage2_metadata": stage2_metadata,
"stage3_metadata": stage3_metadata,
"total_duration_seconds": round(total_duration, 2)
}
return stage1_results, stage2_results, stage3_result, metadata
+106
View File
@@ -0,0 +1,106 @@
"""Helpers to load and format uploaded markdown docs as prompt context."""
from __future__ import annotations
import re
from typing import Optional, List
from . import documents
def _normalize_filename_for_matching(filename: str) -> str:
"""Normalize filename for matching @filename references."""
# Convert to lowercase, replace spaces/underscores/hyphens with single underscore
normalized = filename.lower()
normalized = re.sub(r'[_\s\-]+', '_', normalized)
# Remove .md extension for matching
normalized = normalized.replace('.md', '')
return normalized
def _extract_filename_references(text: str) -> List[str]:
"""Extract @filename references from text."""
# Match @filename patterns (with or without .md extension)
pattern = r'@([a-zA-Z0-9_\s\-\+\.]+)'
matches = re.findall(pattern, text)
# Normalize each match
return [_normalize_filename_for_matching(m) for m in matches]
def _extract_numeric_references(text: str) -> List[int]:
"""Extract numeric document references like @1, @2, @3 from text."""
# Match @ followed by digits
pattern = r'@(\d+)'
matches = re.findall(pattern, text)
# Convert to integers (1-indexed, will be converted to 0-indexed when used)
return [int(m) for m in matches]
def build_docs_context(
conversation_id: str,
user_query: Optional[str] = None,
*,
max_chars: int = 8000,
max_docs: int = 5
) -> Optional[str]:
"""
Return a single markdown string containing (truncated) docs for a conversation.
If user_query is provided and contains references:
- @1, @2, @3 etc. (numeric): Include documents by their numbered position (1-indexed)
- @filename (text): Include documents whose filenames match (fuzzy matching)
- If both are present, numeric references take precedence
Otherwise, include all documents up to max_docs.
"""
all_metas = documents.list_documents(conversation_id)
if not all_metas:
return None
# Check for numeric references first (e.g., @1, @2, @3)
if user_query:
numeric_refs = _extract_numeric_references(user_query)
if numeric_refs:
# Convert 1-indexed to 0-indexed and filter
filtered_metas = []
for num in numeric_refs:
idx = num - 1 # Convert to 0-indexed
if 0 <= idx < len(all_metas):
filtered_metas.append(all_metas[idx])
if filtered_metas:
all_metas = filtered_metas
else:
# If no numeric refs, check for filename references
refs = _extract_filename_references(user_query)
if refs:
filtered_metas = []
for meta in all_metas:
normalized = _normalize_filename_for_matching(meta.filename)
# Check if any reference matches this filename
if any(ref in normalized or normalized in ref for ref in refs):
filtered_metas.append(meta)
if filtered_metas:
all_metas = filtered_metas
# Limit to max_docs
metas = all_metas[:max_docs]
if not metas:
return None
chunks = []
remaining = max_chars
for meta in metas:
if remaining <= 0:
break
text = documents.read_document_text(conversation_id, meta.id)
header = f"\n\n---\nDOC: {meta.filename} ({meta.bytes} bytes)\n---\n"
body = text
if len(header) >= remaining:
break
remaining -= len(header)
if len(body) > remaining:
body = body[: max(0, remaining - 3)] + "..."
remaining -= len(body)
chunks.append(header + body)
return "".join(chunks).strip() if chunks else None
+103
View File
@@ -0,0 +1,103 @@
"""Markdown document storage for conversations.
Stores uploaded .md files on disk under data/docs/<conversation_id>/.
"""
from __future__ import annotations
import os
import re
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import List
from .config import DOCS_DIR, MAX_DOC_BYTES
_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9._ -]+")
def _safe_filename(name: str) -> str:
name = name.strip().replace("\\", "/").split("/")[-1] # drop any path
name = _SAFE_NAME_RE.sub("_", name)
name = name.strip(" .")
if not name:
name = "document.md"
if not name.lower().endswith(".md"):
name = f"{name}.md"
return name
def _conversation_dir(conversation_id: str) -> Path:
base = Path(DOCS_DIR)
return base / conversation_id
def ensure_docs_dir(conversation_id: str) -> Path:
d = _conversation_dir(conversation_id)
d.mkdir(parents=True, exist_ok=True)
return d
@dataclass(frozen=True)
class DocumentMeta:
id: str
filename: str
bytes: int
def save_markdown_document(conversation_id: str, filename: str, content: bytes) -> DocumentMeta:
if len(content) > MAX_DOC_BYTES:
raise ValueError(f"Document too large. Max {MAX_DOC_BYTES} bytes.")
safe_name = _safe_filename(filename)
doc_id = str(uuid.uuid4())
d = ensure_docs_dir(conversation_id)
path = d / f"{doc_id}__{safe_name}"
path.write_bytes(content)
return DocumentMeta(id=doc_id, filename=safe_name, bytes=len(content))
def list_documents(conversation_id: str) -> List[DocumentMeta]:
d = _conversation_dir(conversation_id)
if not d.exists():
return []
out: List[DocumentMeta] = []
for p in sorted(d.iterdir()):
if not p.is_file():
continue
if "__" not in p.name:
continue
doc_id, fname = p.name.split("__", 1)
out.append(DocumentMeta(id=doc_id, filename=fname, bytes=p.stat().st_size))
return out
def read_document_text(conversation_id: str, doc_id: str) -> str:
d = _conversation_dir(conversation_id)
if not d.exists():
raise FileNotFoundError("Conversation docs not found")
matches = [p for p in d.iterdir() if p.is_file() and p.name.startswith(f"{doc_id}__")]
if not matches:
raise FileNotFoundError("Document not found")
raw = matches[0].read_bytes()
# Best-effort UTF-8; replace invalid sequences
return raw.decode("utf-8", errors="replace")
def delete_document(conversation_id: str, doc_id: str) -> None:
d = _conversation_dir(conversation_id)
if not d.exists():
raise FileNotFoundError("Conversation docs not found")
matches = [p for p in d.iterdir() if p.is_file() and p.name.startswith(f"{doc_id}__")]
if not matches:
raise FileNotFoundError("Document not found")
matches[0].unlink()
+132
View File
@@ -0,0 +1,132 @@
"""Unified LLM client.
This module routes LLM requests to OpenAI-compatible servers (Ollama, vLLM, TGI, etc.).
The base URL is determined by:
- If USE_LOCAL_OLLAMA=true: uses http://localhost:11434
- Else if OPENAI_COMPAT_BASE_URL is set: uses that URL
- Else: raises an error (base URL must be configured)
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
from .config import MAX_TOKENS, OPENAI_COMPAT_BASE_URL, LLM_TIMEOUT_SECONDS, DEBUG
def _get_provider_name() -> str:
"""Returns the provider name (always 'openai_compat' now)."""
return "openai_compat"
def _get_max_concurrency() -> int:
"""
Maximum number of in-flight model requests when calling query_models_parallel.
- If LLM_MAX_CONCURRENCY is unset/empty/invalid: unlimited (0)
- If set to 1: strictly sequential
- If set to N>1: at most N in flight
"""
raw = (os.getenv("LLM_MAX_CONCURRENCY") or "").strip()
if not raw:
return 0
try:
v = int(raw)
except ValueError:
return 0
return max(0, v)
def get_provider_info() -> Dict[str, Any]:
"""Get information about the configured provider."""
from .config import OPENAI_COMPAT_BASE_URL
return {
"provider": "openai_compat",
"base_url": OPENAI_COMPAT_BASE_URL
}
async def list_models() -> Optional[List[str]]:
"""List available models from the OpenAI-compatible server."""
from .openai_compat import list_models as _list
return await _list()
async def query_model(
model: str,
messages: List[Dict[str, str]],
timeout: Optional[float] = None,
max_tokens_override: Optional[int] = None,
) -> Optional[Dict[str, Any]]:
"""Query a model via OpenAI-compatible API."""
from .openai_compat import query_model as _query
max_tokens = max_tokens_override if max_tokens_override is not None else MAX_TOKENS
resolved_timeout = timeout if timeout is not None else LLM_TIMEOUT_SECONDS
return await _query(
model,
messages,
max_tokens=max_tokens,
timeout=resolved_timeout,
)
async def query_models_parallel(
models: List[str],
messages: List[Dict[str, str]],
timeout: Optional[float] = None,
max_tokens_override: Optional[int] = None,
) -> Dict[str, Optional[Dict[str, Any]]]:
import asyncio
resolved_timeout = timeout if timeout is not None else LLM_TIMEOUT_SECONDS
limit = _get_max_concurrency()
# If limit is 1, run completely sequentially (one at a time, wait for each to finish)
if limit == 1:
results = {}
for model in models:
if DEBUG:
print(f"[DEBUG] Running model '{model}' sequentially (concurrency=1)")
results[model] = await query_model(
model,
messages,
timeout=resolved_timeout,
max_tokens_override=max_tokens_override,
)
return results
# If limit <= 0 or >= len(models), run all in parallel (no limit)
if limit <= 0 or limit >= len(models):
tasks = [
query_model(
model,
messages,
timeout=resolved_timeout,
max_tokens_override=max_tokens_override,
)
for model in models
]
responses = await asyncio.gather(*tasks)
return {model: response for model, response in zip(models, responses)}
# Otherwise, use semaphore to limit concurrency (2, 3, etc.)
sem = asyncio.Semaphore(limit)
async def _run_one(model: str) -> Optional[Dict[str, Any]]:
async with sem:
return await query_model(
model,
messages,
timeout=resolved_timeout,
max_tokens_override=max_tokens_override,
)
tasks = [_run_one(model) for model in models]
responses = await asyncio.gather(*tasks)
return {model: response for model, response in zip(models, responses)}
+579
View File
@@ -0,0 +1,579 @@
"""FastAPI backend for LLM Council."""
from fastapi import FastAPI, HTTPException, UploadFile, File, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, Response
from pydantic import BaseModel
from typing import List, Dict, Any
import uuid
import json
import asyncio
import time
from datetime import datetime
from . import storage
from . import documents
from .config import MAX_DOC_PREVIEW_CHARS, COUNCIL_MODELS
from .docs_context import build_docs_context
from .llm_client import get_provider_info, list_models as llm_list_models, query_model, LLM_TIMEOUT_SECONDS, MAX_TOKENS
from .council import run_full_council, generate_conversation_title, stage1_collect_responses, stage2_collect_rankings, stage3_synthesize_final, calculate_aggregate_rankings, _format_docs_context
app = FastAPI(title="LLM Council API")
# Enable CORS for local development
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:5174", "http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class CreateConversationRequest(BaseModel):
"""Request to create a new conversation."""
pass
class SendMessageRequest(BaseModel):
"""Request to send a message in a conversation."""
content: str
class ConversationMetadata(BaseModel):
"""Conversation metadata for list view."""
id: str
created_at: str
title: str
message_count: int
class Conversation(BaseModel):
"""Full conversation with all messages."""
id: str
created_at: str
title: str
messages: List[Dict[str, Any]]
@app.get("/")
async def root():
"""Health check endpoint."""
return {"status": "ok", "service": "LLM Council API"}
@app.get("/api/llm/status")
async def llm_status(probe: bool = Query(False, description="If true, query the provider for available models")):
"""
Returns current LLM provider configuration and (optionally) probes the provider.
"""
info = get_provider_info()
if probe:
info["remote_models"] = await llm_list_models()
return info
@app.get("/api/conversations", response_model=List[ConversationMetadata])
async def list_conversations():
"""List all conversations (metadata only)."""
return storage.list_conversations()
@app.post("/api/conversations", response_model=Conversation)
async def create_conversation(request: CreateConversationRequest):
"""Create a new conversation."""
conversation_id = str(uuid.uuid4())
conversation = storage.create_conversation(conversation_id)
return conversation
@app.get("/api/conversations/{conversation_id}", response_model=Conversation)
async def get_conversation(conversation_id: str):
"""Get a specific conversation with all its messages."""
conversation = storage.get_conversation(conversation_id)
if conversation is None:
raise HTTPException(status_code=404, detail="Conversation not found")
return conversation
@app.delete("/api/conversations/{conversation_id}")
async def delete_conversation(conversation_id: str):
"""Delete a conversation and its associated documents."""
try:
storage.delete_conversation(conversation_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
return {"ok": True}
@app.get("/api/conversations/{conversation_id}/documents")
async def list_conversation_documents(conversation_id: str):
"""List uploaded markdown documents for a conversation."""
conversation = storage.get_conversation(conversation_id)
if conversation is None:
raise HTTPException(status_code=404, detail="Conversation not found")
docs = documents.list_documents(conversation_id)
return [{"id": d.id, "filename": d.filename, "bytes": d.bytes} for d in docs]
@app.post("/api/conversations/{conversation_id}/documents")
async def upload_conversation_document(
conversation_id: str,
files: List[UploadFile] = File(default=[]),
file: UploadFile = File(default=None),
):
"""
Upload one or more markdown documents (.md) for a conversation.
Backwards compatible:
- old clients send a single "file" field
- new clients can send multiple "files" fields
"""
conversation = storage.get_conversation(conversation_id)
if conversation is None:
raise HTTPException(status_code=404, detail="Conversation not found")
incoming: List[UploadFile] = []
if file is not None:
incoming.append(file)
if files:
incoming.extend(files)
if not incoming:
raise HTTPException(status_code=400, detail="No files uploaded")
uploaded = []
for f in incoming:
filename = f.filename or "document.md"
if not filename.lower().endswith(".md"):
raise HTTPException(status_code=400, detail="Only .md files are supported")
content = await f.read()
try:
meta = documents.save_markdown_document(conversation_id, filename, content)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
uploaded.append({"id": meta.id, "filename": meta.filename, "bytes": meta.bytes})
# Back-compat: if single file uploaded, return the single object shape.
if len(uploaded) == 1:
return uploaded[0]
return {"uploaded": uploaded}
@app.get("/api/conversations/{conversation_id}/documents/{doc_id}")
async def get_conversation_document(conversation_id: str, doc_id: str):
"""Fetch a markdown document's text (truncated for safety)."""
conversation = storage.get_conversation(conversation_id)
if conversation is None:
raise HTTPException(status_code=404, detail="Conversation not found")
try:
text = documents.read_document_text(conversation_id, doc_id)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Document not found")
if len(text) > MAX_DOC_PREVIEW_CHARS:
text = text[: MAX_DOC_PREVIEW_CHARS - 3] + "..."
return {"id": doc_id, "content": text}
@app.delete("/api/conversations/{conversation_id}/documents/{doc_id}")
async def delete_conversation_document(conversation_id: str, doc_id: str):
"""Delete a previously uploaded document."""
conversation = storage.get_conversation(conversation_id)
if conversation is None:
raise HTTPException(status_code=404, detail="Conversation not found")
try:
documents.delete_document(conversation_id, doc_id)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Document not found")
return {"ok": True}
@app.patch("/api/conversations/{conversation_id}/title")
async def update_conversation_title_endpoint(conversation_id: str, request: dict):
"""Update the title of a conversation."""
conversation = storage.get_conversation(conversation_id)
if conversation is None:
raise HTTPException(status_code=404, detail="Conversation not found")
new_title = request.get('title', '').strip()
if not new_title:
raise HTTPException(status_code=400, detail="Title cannot be empty")
storage.update_conversation_title(conversation_id, new_title)
return {"ok": True, "title": new_title}
@app.get("/api/conversations/search")
async def search_conversations(q: str = ""):
"""Search conversations by title and content."""
if not q or len(q.strip()) < 2:
return []
query = q.strip().lower()
all_conversations = storage.list_conversations()
results = []
for conv_meta in all_conversations:
# Search in title
title_match = query in (conv_meta.get('title', '') or '').lower()
# Search in content
conv = storage.get_conversation(conv_meta['id'])
content_match = False
if conv:
for msg in conv.get('messages', []):
if query in msg.get('content', '').lower():
content_match = True
break
if title_match or content_match:
results.append(conv_meta)
return results
@app.post("/api/conversations/{conversation_id}/message")
async def send_message(conversation_id: str, request: SendMessageRequest):
"""
Send a message and run the 3-stage council process.
Returns the complete response with all stages.
"""
# Check if conversation exists
conversation = storage.get_conversation(conversation_id)
if conversation is None:
raise HTTPException(status_code=404, detail="Conversation not found")
# Check if this is the first message
is_first_message = len(conversation["messages"]) == 0
# Add user message
storage.add_user_message(conversation_id, request.content)
# If this is the first message, generate a title
if is_first_message:
title = await generate_conversation_title(request.content)
storage.update_conversation_title(conversation_id, title)
# Run the 3-stage council process
docs_text = build_docs_context(conversation_id, user_query=request.content)
stage1_results, stage2_results, stage3_result, metadata = await run_full_council(
request.content,
docs_text=docs_text,
)
# Add assistant message with all stages
storage.add_assistant_message(
conversation_id,
stage1_results,
stage2_results,
stage3_result,
metadata
)
# Return the complete response with metadata
return {
"stage1": stage1_results,
"stage2": stage2_results,
"stage3": stage3_result,
"metadata": metadata
}
@app.post("/api/conversations/{conversation_id}/message/stream")
async def send_message_stream(conversation_id: str, request: SendMessageRequest):
"""
Send a message and stream the 3-stage council process.
Returns Server-Sent Events as each stage completes.
"""
# Check if conversation exists
conversation = storage.get_conversation(conversation_id)
if conversation is None:
raise HTTPException(status_code=404, detail="Conversation not found")
# Check if this is the first message
is_first_message = len(conversation["messages"]) == 0
async def event_generator():
try:
# Add user message
storage.add_user_message(conversation_id, request.content)
# Start title generation in parallel (don't await yet)
title_task = None
if is_first_message:
title_task = asyncio.create_task(generate_conversation_title(request.content))
# Load docs context once per request
docs_text = build_docs_context(conversation_id, user_query=request.content)
# Stage 1: Collect responses - stream individual responses as they complete
yield f"data: {json.dumps({'type': 'stage1_start'})}\n\n"
# Stream responses as they complete
from .config import COUNCIL_MODELS, OPENAI_COMPAT_BASE_URL, DEBUG
from .llm_client import query_model, LLM_TIMEOUT_SECONDS, MAX_TOKENS
from .council import _format_docs_context
if DEBUG:
print(f"[DEBUG] Stage 1: Querying {len(COUNCIL_MODELS)} models: {COUNCIL_MODELS}")
print(f"[DEBUG] Using base URL: {OPENAI_COMPAT_BASE_URL}")
start_time = time.time()
prompt = f"{request.content}{_format_docs_context(docs_text)}"
messages = [{"role": "user", "content": prompt}]
stage1_results = []
successful_models = []
failed_models = []
response_queue = asyncio.Queue()
async def process_model(model: str):
try:
if DEBUG:
print(f"[DEBUG] Processing model: {model}")
response = await query_model(model, messages, timeout=LLM_TIMEOUT_SECONDS, max_tokens_override=MAX_TOKENS)
if response is not None:
result = {"model": model, "response": response.get('content', '')}
await response_queue.put(('success', model, result))
if DEBUG:
print(f"[DEBUG] Model {model} succeeded")
else:
await response_queue.put(('failed', model, None))
if DEBUG:
print(f"[DEBUG] Model {model} failed (returned None)")
except Exception as e:
await response_queue.put(('failed', model, None))
if DEBUG:
print(f"[DEBUG] Model {model} exception: {e}")
# Create tasks
tasks = [asyncio.create_task(process_model(model)) for model in COUNCIL_MODELS]
# Process responses as they arrive
completed = 0
while completed < len(COUNCIL_MODELS):
status, model, result = await response_queue.get()
if status == 'success':
stage1_results.append(result)
successful_models.append(model)
# Stream this response immediately
yield f"data: {json.dumps({'type': 'stage1_response', 'model': model, 'response': result})}\n\n"
else:
failed_models.append(model)
# Stream failure notification
yield f"data: {json.dumps({'type': 'stage1_response_failed', 'model': model})}\n\n"
completed += 1
# Wait for all tasks to complete
await asyncio.gather(*tasks, return_exceptions=True)
duration = time.time() - start_time
stage1_metadata = {
"duration_seconds": round(duration, 2),
"successful_models": successful_models,
"failed_models": failed_models,
"total_models": len(COUNCIL_MODELS)
}
yield f"data: {json.dumps({'type': 'stage1_complete', 'data': stage1_results, 'metadata': stage1_metadata})}\n\n"
# Stage 2: Collect rankings
yield f"data: {json.dumps({'type': 'stage2_start'})}\n\n"
stage2_results, label_to_model, stage2_metadata = await stage2_collect_rankings(request.content, stage1_results, docs_text=docs_text)
aggregate_rankings = calculate_aggregate_rankings(stage2_results, label_to_model)
yield f"data: {json.dumps({'type': 'stage2_complete', 'data': stage2_results, 'metadata': {'label_to_model': label_to_model, 'aggregate_rankings': aggregate_rankings, 'stage2_metadata': stage2_metadata}})}\n\n"
# Stage 3: Synthesize final answer
yield f"data: {json.dumps({'type': 'stage3_start'})}\n\n"
stage3_result, stage3_metadata = await stage3_synthesize_final(request.content, stage1_results, stage2_results, docs_text=docs_text)
yield f"data: {json.dumps({'type': 'stage3_complete', 'data': stage3_result, 'metadata': stage3_metadata})}\n\n"
# Wait for title generation if it was started
if title_task:
title = await title_task
storage.update_conversation_title(conversation_id, title)
yield f"data: {json.dumps({'type': 'title_complete', 'data': {'title': title}})}\n\n"
# Prepare metadata
metadata = {
"label_to_model": label_to_model,
"aggregate_rankings": aggregate_rankings,
"stage1_metadata": stage1_metadata,
"stage2_metadata": stage2_metadata,
"stage3_metadata": stage3_metadata
}
# Save complete assistant message
storage.add_assistant_message(
conversation_id,
stage1_results,
stage2_results,
stage3_result,
metadata
)
# Send completion event
yield f"data: {json.dumps({'type': 'complete'})}\n\n"
except Exception as e:
# Send error event with details
import traceback
from .config import DEBUG
error_msg = str(e)
if DEBUG:
error_msg += f"\n{traceback.format_exc()}"
print(f"[ERROR] Stream error: {error_msg}")
yield f"data: {json.dumps({'type': 'error', 'message': error_msg})}\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
}
)
@app.get("/api/conversations/{conversation_id}/export")
async def export_conversation_report(conversation_id: str):
"""Export conversation as a markdown report file."""
conversation = storage.get_conversation(conversation_id)
if conversation is None:
raise HTTPException(status_code=404, detail="Conversation not found")
# Get document list to map @1, @2, etc. to filenames
from . import documents
doc_list = documents.list_documents(conversation_id)
doc_map = {} # Maps @1 -> filename, @2 -> filename, etc.
for idx, doc in enumerate(doc_list, 1):
doc_map[f"@{idx}"] = doc.filename
doc_map[f"@ {idx}"] = doc.filename # Also handle @ 1 (with space)
def replace_doc_references(text: str) -> str:
"""Replace @1, @2, etc. with actual filenames."""
import re
# Replace @1, @2, @3, etc. with filenames
for ref, filename in doc_map.items():
# Match @1, @2, etc. (with optional space after @)
pattern = re.escape(ref)
text = re.sub(pattern, filename, text)
return text
# Generate markdown report
lines = []
lines.append(f"# {conversation.get('title', 'Conversation')}\n")
# Add metadata
created_at = conversation.get('created_at', '')
if created_at:
try:
dt = datetime.fromisoformat(created_at.replace('Z', '+00:00'))
lines.append(f"**Created:** {dt.strftime('%Y-%m-%d %H:%M:%S UTC')}\n")
except:
lines.append(f"**Created:** {created_at}\n")
lines.append(f"**Conversation ID:** {conversation_id}\n")
lines.append("\n---\n\n")
# Add messages
for msg_idx, msg in enumerate(conversation.get('messages', []), 1):
if msg['role'] == 'user':
lines.append(f"## User Message {msg_idx}\n\n")
content = replace_doc_references(msg['content'])
lines.append(f"{content}\n\n")
lines.append("---\n\n")
elif msg['role'] == 'assistant':
lines.append(f"## LLM Council Response {msg_idx}\n\n")
metadata = msg.get('metadata', {})
# Stage 1
if msg.get('stage1'):
stage1_meta = metadata.get('stage1_metadata', {})
duration = stage1_meta.get('duration_seconds', 0)
successful = len(stage1_meta.get('successful_models', []))
total = stage1_meta.get('total_models', 0)
lines.append("### Stage 1: Individual Responses\n\n")
if duration:
lines.append(f"*Duration: {duration}s | Successful: {successful}/{total} models*\n\n")
for response in msg['stage1']:
lines.append(f"#### {response['model']}\n\n")
content = replace_doc_references(response['response'])
lines.append(f"{content}\n\n")
lines.append("\n---\n\n")
# Stage 2
if msg.get('stage2'):
stage2_meta = metadata.get('stage2_metadata', {})
duration = stage2_meta.get('duration_seconds', 0)
successful = len(stage2_meta.get('successful_models', []))
total = stage2_meta.get('total_models', 0)
lines.append("### Stage 2: Peer Rankings\n\n")
if duration:
lines.append(f"*Duration: {duration}s | Successful: {successful}/{total} models*\n\n")
for ranking in msg['stage2']:
lines.append(f"#### {ranking['model']}\n\n")
content = replace_doc_references(ranking['ranking'])
lines.append(f"{content}\n\n")
# Add aggregate rankings if available
agg_rankings = metadata.get('aggregate_rankings', [])
if agg_rankings:
lines.append("#### Aggregate Rankings\n\n")
for item in agg_rankings:
lines.append(f"- **{item['model']}**: Average rank {item['average_rank']:.2f}\n")
lines.append("\n")
lines.append("\n---\n\n")
# Stage 3
if msg.get('stage3'):
stage3_meta = metadata.get('stage3_metadata', {})
duration = stage3_meta.get('duration_seconds', 0)
model = stage3_meta.get('model', msg['stage3'].get('model', 'Unknown'))
lines.append("### Stage 3: Final Synthesis\n\n")
if duration:
lines.append(f"*Duration: {duration}s | Model: {model}*\n\n")
content = replace_doc_references(msg['stage3'].get('response', ''))
lines.append(f"{content}\n\n")
# Total duration
total_duration = metadata.get('total_duration_seconds')
if total_duration:
lines.append(f"**Total processing time:** {total_duration}s\n\n")
lines.append("---\n\n")
# Convert to string
content = "".join(lines)
# Generate filename
title = conversation.get('title', 'conversation')
# Sanitize filename
safe_title = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in title)
safe_title = safe_title[:50].strip() # Limit length
filename = f"{safe_title}_{conversation_id[:8]}.md"
return Response(
content=content,
media_type="text/markdown",
headers={
"Content-Disposition": f'attachment; filename="{filename}"'
}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001)
+253
View File
@@ -0,0 +1,253 @@
"""OpenAI-compatible API client (for Ollama / vLLM / TGI / OpenAI-style servers).
This lets LLM Council talk to any OpenAI-compatible server (local Ollama,
remote Ollama, vLLM, TGI, etc.).
"""
from __future__ import annotations
import asyncio
import os
from typing import Any, Dict, List, Optional
import httpx
from .config import (
OPENAI_COMPAT_BASE_URL,
OPENAI_COMPAT_RETRIES,
OPENAI_COMPAT_RETRY_BACKOFF_SECONDS,
OPENAI_COMPAT_TIMEOUT_SECONDS,
OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS,
OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS,
OPENAI_COMPAT_POOL_TIMEOUT_SECONDS,
DEBUG,
)
def _resolve_chat_completions_url(base_url: str) -> str:
"""
Accepts either:
- http://host:8000 -> http://host:8000/v1/chat/completions
- http://host:8000/v1 -> http://host:8000/v1/chat/completions
- http://host:8000/v1/ -> http://host:8000/v1/chat/completions
"""
base = base_url.rstrip("/")
if base.endswith("/v1"):
return f"{base}/chat/completions"
if "/v1/" in f"{base}/":
# Already has /v1 somewhere; assume caller gave full root including /v1
return f"{base}/chat/completions"
return f"{base}/v1/chat/completions"
def _resolve_models_url(base_url: str) -> str:
base = base_url.rstrip("/")
if base.endswith("/v1"):
return f"{base}/models"
if "/v1/" in f"{base}/":
return f"{base}/models"
return f"{base}/v1/models"
def _resolve_ollama_tags_url(base_url: str) -> str:
"""Resolve Ollama's native /api/tags endpoint URL."""
base = base_url.rstrip("/")
return f"{base}/api/tags"
def _should_retry(status_code: int) -> bool:
return status_code in {408, 409, 425, 429, 500, 502, 503, 504}
async def query_model(
model: str,
messages: List[Dict[str, str]],
*,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
max_tokens: int = 2048,
timeout: Optional[float] = None,
client: Optional[httpx.AsyncClient] = None,
) -> Optional[Dict[str, Any]]:
"""Query a model via an OpenAI-compatible chat completions endpoint."""
resolved_base_url = base_url or OPENAI_COMPAT_BASE_URL
if not resolved_base_url:
print("Error querying OpenAI-compatible provider: OPENAI_COMPAT_BASE_URL not set")
return None
resolved_api_key = api_key if api_key is not None else os.getenv("OPENAI_COMPAT_API_KEY")
resolved_timeout = OPENAI_COMPAT_TIMEOUT_SECONDS if timeout is None else timeout
retries = OPENAI_COMPAT_RETRIES
backoff = OPENAI_COMPAT_RETRY_BACKOFF_SECONDS
url = _resolve_chat_completions_url(resolved_base_url)
headers = {"Content-Type": "application/json"}
if resolved_api_key:
headers["Authorization"] = f"Bearer {resolved_api_key}"
payload: Dict[str, Any] = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
}
if DEBUG:
print(f"[DEBUG] Querying model '{model}' at {url} (timeout={resolved_timeout}s, max_tokens={max_tokens})")
close_client = False
try:
if client is None:
# Use explicit Timeout object to ensure read timeout is set correctly
# For LLM requests, we need a long read timeout since generation can take time
timeout_config = httpx.Timeout(
connect=OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS,
read=resolved_timeout, # Read timeout: use the configured timeout
write=OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS,
pool=OPENAI_COMPAT_POOL_TIMEOUT_SECONDS
)
client = httpx.AsyncClient(timeout=timeout_config)
close_client = True
attempt = 0
while True:
if DEBUG:
print(f"[DEBUG] Attempt {attempt + 1}/{retries + 1}: POST {url}")
resp = await client.post(url, headers=headers, json=payload)
if resp.status_code != 200:
# Preserve server-provided error text for debugging.
try:
err_json = resp.json()
err_msg = err_json.get("error", {}).get("message", resp.text)
except Exception:
err_msg = resp.text
if attempt < retries and _should_retry(resp.status_code):
await asyncio.sleep(backoff * (2**attempt))
attempt += 1
continue
print(f"Error querying model {model} (HTTP {resp.status_code}): {err_msg}")
return None
data = resp.json()
msg = data["choices"][0]["message"]
if DEBUG:
print(f"[DEBUG] Model '{model}' responded successfully")
return {
"content": msg.get("content"),
"reasoning_details": msg.get("reasoning_details"),
}
except httpx.TimeoutException as e:
print(f"[ERROR] Model '{model}' timeout after {resolved_timeout}s at {url}")
print(
f"[ERROR] This can mean the model is loading / slow, OR that the server/port is unreachable.\n"
f"[ERROR] Check connectivity: curl {resolved_base_url}/api/tags"
)
return None
except httpx.ConnectError as e:
print(f"[ERROR] Cannot connect to {url}: {e}")
print(f"[ERROR] Is Ollama running? Check: curl {resolved_base_url}/api/tags")
return None
except Exception as e:
print(f"[ERROR] Unexpected error querying model '{model}' at {url}: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
return None
finally:
if close_client and client is not None:
await client.aclose()
async def list_models(
*,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
timeout: Optional[float] = None,
client: Optional[httpx.AsyncClient] = None,
) -> Optional[List[str]]:
"""Return model IDs from an OpenAI-compatible server (/v1/models)."""
resolved_base_url = base_url or OPENAI_COMPAT_BASE_URL
if not resolved_base_url:
return None
resolved_api_key = api_key if api_key is not None else os.getenv("OPENAI_COMPAT_API_KEY")
resolved_timeout = OPENAI_COMPAT_TIMEOUT_SECONDS if timeout is None else timeout
retries = OPENAI_COMPAT_RETRIES
backoff = OPENAI_COMPAT_RETRY_BACKOFF_SECONDS
# Try OpenAI-compatible endpoint first
url = _resolve_models_url(resolved_base_url)
headers = {"Content-Type": "application/json"}
if resolved_api_key:
headers["Authorization"] = f"Bearer {resolved_api_key}"
close_client = False
try:
if client is None:
# Use explicit Timeout object for list_models (faster operation)
timeout_config = httpx.Timeout(
connect=OPENAI_COMPAT_CONNECT_TIMEOUT_SECONDS,
read=resolved_timeout,
write=OPENAI_COMPAT_WRITE_TIMEOUT_SECONDS,
pool=OPENAI_COMPAT_POOL_TIMEOUT_SECONDS
)
client = httpx.AsyncClient(timeout=timeout_config)
close_client = True
attempt = 0
while True:
resp = await client.get(url, headers=headers)
if resp.status_code == 200:
data = resp.json()
# Try OpenAI-compatible format first
items = data.get("data", [])
if items:
ids: List[str] = []
for it in items:
mid = it.get("id")
if mid:
ids.append(mid)
return ids
# Fallback: check if it's already in Ollama format
items = data.get("models", [])
if items:
ids: List[str] = []
for it in items:
mid = it.get("name") or it.get("model")
if mid:
ids.append(mid)
return ids
return []
# If /v1/models fails, try Ollama's native /api/tags endpoint
if resp.status_code == 404 and attempt == 0:
ollama_url = _resolve_ollama_tags_url(resolved_base_url)
if DEBUG:
print(f"[DEBUG] /v1/models not found, trying Ollama native API: {ollama_url}")
resp = await client.get(ollama_url, headers=headers)
if resp.status_code == 200:
data = resp.json()
items = data.get("models", [])
if items:
ids: List[str] = []
for it in items:
mid = it.get("name") or it.get("model")
if mid:
ids.append(mid)
return ids
if attempt < retries and _should_retry(resp.status_code):
await asyncio.sleep(backoff * (2**attempt))
attempt += 1
continue
return None
except Exception as e:
if DEBUG:
msg = str(e) if str(e) else "(no message)"
print(f"[DEBUG] Error listing models: {type(e).__name__}: {msg}")
return None
finally:
if close_client and client is not None:
await client.aclose()
+224
View File
@@ -0,0 +1,224 @@
"""JSON-based storage for conversations."""
import json
import os
from datetime import datetime
from typing import List, Dict, Any, Optional
from pathlib import Path
from .config import DATA_DIR
def ensure_data_dir():
"""Ensure the data directory exists."""
Path(DATA_DIR).mkdir(parents=True, exist_ok=True)
def get_conversation_path(conversation_id: str) -> str:
"""Get the file path for a conversation."""
return os.path.join(DATA_DIR, f"{conversation_id}.json")
def create_conversation(conversation_id: str) -> Dict[str, Any]:
"""
Create a new conversation.
Args:
conversation_id: Unique identifier for the conversation
Returns:
New conversation dict
"""
ensure_data_dir()
conversation = {
"id": conversation_id,
"created_at": datetime.utcnow().isoformat(),
"title": "New Conversation",
"messages": []
}
# Save to file
path = get_conversation_path(conversation_id)
with open(path, 'w') as f:
json.dump(conversation, f, indent=2)
return conversation
def get_conversation(conversation_id: str) -> Optional[Dict[str, Any]]:
"""
Load a conversation from storage.
Args:
conversation_id: Unique identifier for the conversation
Returns:
Conversation dict or None if not found
"""
path = get_conversation_path(conversation_id)
if not os.path.exists(path):
return None
with open(path, 'r') as f:
return json.load(f)
def save_conversation(conversation: Dict[str, Any]):
"""
Save a conversation to storage.
Args:
conversation: Conversation dict to save
"""
ensure_data_dir()
path = get_conversation_path(conversation['id'])
with open(path, 'w') as f:
json.dump(conversation, f, indent=2)
def list_conversations(include_archived: bool = False) -> List[Dict[str, Any]]:
"""
List all conversations (metadata only).
Args:
include_archived: If True, include archived conversations
Returns:
List of conversation metadata dicts
"""
ensure_data_dir()
conversations = []
for filename in os.listdir(DATA_DIR):
if filename.endswith('.json'):
path = os.path.join(DATA_DIR, filename)
with open(path, 'r') as f:
data = json.load(f)
# Return metadata only
conversations.append({
"id": data["id"],
"created_at": data["created_at"],
"title": data.get("title", "New Conversation"),
"message_count": len(data["messages"])
})
# Sort by creation time, newest first
conversations.sort(key=lambda x: x["created_at"], reverse=True)
return conversations
def add_user_message(conversation_id: str, content: str):
"""
Add a user message to a conversation.
Args:
conversation_id: Conversation identifier
content: User message content
"""
conversation = get_conversation(conversation_id)
if conversation is None:
raise ValueError(f"Conversation {conversation_id} not found")
conversation["messages"].append({
"role": "user",
"content": content
})
save_conversation(conversation)
def add_assistant_message(
conversation_id: str,
stage1: List[Dict[str, Any]],
stage2: List[Dict[str, Any]],
stage3: Dict[str, Any],
metadata: Optional[Dict[str, Any]] = None
):
"""
Add an assistant message with all 3 stages to a conversation.
Args:
conversation_id: Conversation identifier
stage1: List of individual model responses
stage2: List of model rankings
stage3: Final synthesized response
metadata: Optional metadata dict with timing and other info
"""
conversation = get_conversation(conversation_id)
if conversation is None:
raise ValueError(f"Conversation {conversation_id} not found")
message = {
"role": "assistant",
"stage1": stage1,
"stage2": stage2,
"stage3": stage3
}
if metadata:
message["metadata"] = metadata
conversation["messages"].append(message)
save_conversation(conversation)
def update_conversation_title(conversation_id: str, title: str):
"""
Update the title of a conversation.
Args:
conversation_id: Conversation identifier
title: New title for the conversation
"""
conversation = get_conversation(conversation_id)
if conversation is None:
raise ValueError(f"Conversation {conversation_id} not found")
conversation["title"] = title
save_conversation(conversation)
def delete_conversation(conversation_id: str):
"""
Delete a conversation (and its associated documents).
Args:
conversation_id: Conversation identifier
"""
path = get_conversation_path(conversation_id)
if not os.path.exists(path):
raise ValueError(f"Conversation {conversation_id} not found")
# Delete the conversation file
os.remove(path)
# Also delete associated documents directory
from .documents import _conversation_dir
docs_dir = _conversation_dir(conversation_id)
if docs_dir.exists():
import shutil
shutil.rmtree(docs_dir, ignore_errors=True)
def archive_conversation(conversation_id: str, archived: bool = True):
"""
Archive or unarchive a conversation.
Args:
conversation_id: Conversation identifier
archived: True to archive, False to unarchive
"""
conversation = get_conversation(conversation_id)
if conversation is None:
raise ValueError(f"Conversation {conversation_id} not found")
conversation["archived"] = archived
if archived:
conversation["archived_at"] = datetime.utcnow().isoformat()
else:
conversation.pop("archived_at", None)
save_conversation(conversation)
+35
View File
@@ -0,0 +1,35 @@
import importlib
import os
import unittest
class TestConfigEnvOverrides(unittest.TestCase):
def setUp(self):
self._old_env = dict(os.environ)
def tearDown(self):
os.environ.clear()
os.environ.update(self._old_env)
def test_council_models_override_from_env_csv(self):
os.environ["COUNCIL_MODELS"] = "a,b, c"
import backend.config as config
importlib.reload(config)
self.assertEqual(config.COUNCIL_MODELS, ["a", "b", "c"])
def test_chairman_model_override(self):
os.environ["CHAIRMAN_MODEL"] = "chair"
import backend.config as config
importlib.reload(config)
self.assertEqual(config.CHAIRMAN_MODEL, "chair")
def test_max_tokens_override(self):
os.environ["MAX_TOKENS"] = "1234"
import backend.config as config
importlib.reload(config)
self.assertEqual(config.MAX_TOKENS, 1234)
@@ -0,0 +1,60 @@
import importlib
import os
import shutil
import tempfile
import unittest
import httpx
class TestDocPreviewTruncation(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self._old_env = dict(os.environ)
self.tmp = tempfile.mkdtemp(prefix="llm-council-docprev-")
os.environ["DOCS_DIR"] = self.tmp
os.environ["MAX_DOC_BYTES"] = "1000000"
os.environ["MAX_DOC_PREVIEW_CHARS"] = "10"
import backend.config as config
import backend.documents as documents
import backend.main as main
importlib.reload(config)
importlib.reload(documents)
self.main = importlib.reload(main)
self.client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.main.app),
base_url="http://test",
)
# Create a conversation
resp = await self.client.post("/api/conversations", json={})
resp.raise_for_status()
self.conversation_id = resp.json()["id"]
# Upload a long doc
files = {"file": ("long.md", b"0123456789ABCDEFGHIJ", "text/markdown")}
up = await self.client.post(
f"/api/conversations/{self.conversation_id}/documents",
files=files,
)
up.raise_for_status()
self.doc_id = up.json()["id"]
async def asyncTearDown(self):
await self.client.aclose()
os.environ.clear()
os.environ.update(self._old_env)
shutil.rmtree(self.tmp, ignore_errors=True)
async def test_preview_truncates(self):
resp = await self.client.get(
f"/api/conversations/{self.conversation_id}/documents/{self.doc_id}"
)
self.assertEqual(resp.status_code, 200)
content = resp.json()["content"]
self.assertEqual(len(content), 10)
self.assertTrue(content.endswith("..."))
+110
View File
@@ -0,0 +1,110 @@
import os
import shutil
import tempfile
import unittest
import importlib
import httpx
class TestDocumentsApi(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self._old_env = dict(os.environ)
self.tmp = tempfile.mkdtemp(prefix="llm-council-docsapi-")
os.environ["DOCS_DIR"] = self.tmp
os.environ["MAX_DOC_BYTES"] = "1000000"
# Reload config/documents so they see DOCS_DIR override
import backend.config as config
import backend.documents as documents
import backend.main as main
importlib.reload(config)
importlib.reload(documents)
self.main = importlib.reload(main)
self.transport = httpx.ASGITransport(app=self.main.app)
self.client = httpx.AsyncClient(transport=self.transport, base_url="http://test")
# Create a conversation
resp = await self.client.post("/api/conversations", json={})
resp.raise_for_status()
self.conversation_id = resp.json()["id"]
async def asyncTearDown(self):
await self.client.aclose()
os.environ.clear()
os.environ.update(self._old_env)
shutil.rmtree(self.tmp, ignore_errors=True)
async def test_upload_and_list_documents(self):
# Upload
files = {"file": ("notes.md", b"# Hi\n", "text/markdown")}
resp = await self.client.post(
f"/api/conversations/{self.conversation_id}/documents",
files=files,
)
self.assertEqual(resp.status_code, 200, resp.text)
meta = resp.json()
self.assertIn("id", meta)
self.assertEqual(meta["filename"], "notes.md")
# List
resp2 = await self.client.get(
f"/api/conversations/{self.conversation_id}/documents"
)
self.assertEqual(resp2.status_code, 200, resp2.text)
items = resp2.json()
self.assertEqual(len(items), 1)
self.assertEqual(items[0]["id"], meta["id"])
async def test_upload_multiple_documents(self):
files = [
("files", ("a.md", b"one", "text/markdown")),
("files", ("b.md", b"two", "text/markdown")),
]
resp = await self.client.post(
f"/api/conversations/{self.conversation_id}/documents",
files=files,
)
self.assertEqual(resp.status_code, 200, resp.text)
payload = resp.json()
self.assertIn("uploaded", payload)
self.assertEqual(len(payload["uploaded"]), 2)
self.assertEqual({d["filename"] for d in payload["uploaded"]}, {"a.md", "b.md"})
async def test_rejects_non_md(self):
files = {"file": ("notes.txt", b"hello", "text/plain")}
resp = await self.client.post(
f"/api/conversations/{self.conversation_id}/documents",
files=files,
)
self.assertEqual(resp.status_code, 400)
async def test_get_and_delete_document(self):
files = {"file": ("a.md", b"hello", "text/markdown")}
up = await self.client.post(
f"/api/conversations/{self.conversation_id}/documents",
files=files,
)
self.assertEqual(up.status_code, 200)
doc_id = up.json()["id"]
get = await self.client.get(
f"/api/conversations/{self.conversation_id}/documents/{doc_id}"
)
self.assertEqual(get.status_code, 200)
self.assertEqual(get.json()["content"], "hello")
dele = await self.client.delete(
f"/api/conversations/{self.conversation_id}/documents/{doc_id}"
)
self.assertEqual(dele.status_code, 200)
self.assertTrue(dele.json()["ok"])
get2 = await self.client.get(
f"/api/conversations/{self.conversation_id}/documents/{doc_id}"
)
self.assertEqual(get2.status_code, 404)
+38
View File
@@ -0,0 +1,38 @@
import os
import shutil
import tempfile
import unittest
import importlib
class TestDocsContext(unittest.TestCase):
def setUp(self):
self._old_env = dict(os.environ)
self.tmp = tempfile.mkdtemp(prefix="llm-council-docsctx-")
os.environ["DOCS_DIR"] = self.tmp
os.environ["MAX_DOC_BYTES"] = "1000000"
import backend.config as config
import backend.documents as documents
import backend.docs_context as docs_context
self.config = importlib.reload(config)
self.documents = importlib.reload(documents)
self.docs_context = importlib.reload(docs_context)
def tearDown(self):
os.environ.clear()
os.environ.update(self._old_env)
shutil.rmtree(self.tmp, ignore_errors=True)
def test_build_docs_context_truncates(self):
conv = "c1"
self.documents.save_markdown_document(conv, "a.md", b"A" * 50)
self.documents.save_markdown_document(conv, "b.md", b"B" * 50)
ctx = self.docs_context.build_docs_context(conv, max_chars=60, max_docs=5)
self.assertIsNotNone(ctx)
self.assertIn("DOC:", ctx)
self.assertTrue(len(ctx) <= 60)
+48
View File
@@ -0,0 +1,48 @@
import os
import shutil
import tempfile
import unittest
import importlib
class TestDocumentsStorage(unittest.TestCase):
def setUp(self):
self._old_env = dict(os.environ)
self.tmp = tempfile.mkdtemp(prefix="llm-council-docs-")
os.environ["DOCS_DIR"] = self.tmp
os.environ["MAX_DOC_BYTES"] = "100"
import backend.config as config
import backend.documents as documents
self.config = importlib.reload(config)
self.documents = importlib.reload(documents)
def tearDown(self):
os.environ.clear()
os.environ.update(self._old_env)
shutil.rmtree(self.tmp, ignore_errors=True)
def test_save_and_list_document(self):
meta = self.documents.save_markdown_document(
"conv1",
"../weird/name.md",
b"# Hello\n",
)
self.assertTrue(meta.id)
self.assertEqual(meta.filename, "name.md")
self.assertEqual(meta.bytes, 8)
listed = self.documents.list_documents("conv1")
self.assertEqual(len(listed), 1)
self.assertEqual(listed[0].id, meta.id)
self.assertEqual(listed[0].filename, "name.md")
text = self.documents.read_document_text("conv1", meta.id)
self.assertIn("# Hello", text)
def test_rejects_too_large(self):
with self.assertRaises(ValueError):
self.documents.save_markdown_document("conv1", "a.md", b"x" * 101)
+65
View File
@@ -0,0 +1,65 @@
import os
import unittest
class TestProviderSelection(unittest.TestCase):
def setUp(self):
self._old_env = dict(os.environ)
def tearDown(self):
os.environ.clear()
os.environ.update(self._old_env)
def test_always_returns_openai_compat(self):
"""Provider is always 'openai_compat' now (OpenRouter removed)."""
from backend.llm_client import _get_provider_name
# Should always return openai_compat regardless of env vars
self.assertEqual(_get_provider_name(), "openai_compat")
# Test with different env var combinations
os.environ["OPENAI_COMPAT_BASE_URL"] = "http://gpu:8000"
self.assertEqual(_get_provider_name(), "openai_compat")
os.environ.pop("OPENAI_COMPAT_BASE_URL", None)
self.assertEqual(_get_provider_name(), "openai_compat")
class TestParallelConcurrency(unittest.IsolatedAsyncioTestCase):
async def test_query_models_parallel_respects_llm_max_concurrency(self):
import asyncio
import backend.llm_client as lc
old_env = dict(os.environ)
old_query_model = lc.query_model
in_flight = 0
max_in_flight = 0
lock = asyncio.Lock()
async def fake_query_model(model, messages, timeout=120.0, max_tokens_override=None):
nonlocal in_flight, max_in_flight
async with lock:
in_flight += 1
max_in_flight = max(max_in_flight, in_flight)
# ensure overlap is possible without the semaphore
await asyncio.sleep(0.02)
async with lock:
in_flight -= 1
return {"content": model}
try:
os.environ["LLM_MAX_CONCURRENCY"] = "1"
lc.query_model = fake_query_model
models = ["m1", "m2", "m3"]
out = await lc.query_models_parallel(models, [{"role": "user", "content": "hi"}])
self.assertEqual(set(out.keys()), set(models))
self.assertEqual(max_in_flight, 1)
finally:
lc.query_model = old_query_model
os.environ.clear()
os.environ.update(old_env)
+36
View File
@@ -0,0 +1,36 @@
import importlib
import os
import unittest
import httpx
class TestLlmStatusEndpoint(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self._old_env = dict(os.environ)
os.environ["OPENAI_COMPAT_BASE_URL"] = "http://localhost:11434"
os.environ.pop("USE_LOCAL_OLLAMA", None) # Clear this so OPENAI_COMPAT_BASE_URL is used
import backend.config as config
import backend.main as main
importlib.reload(config) # Reload config to pick up env changes
self.main = importlib.reload(main)
self.client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.main.app),
base_url="http://test",
)
async def asyncTearDown(self):
await self.client.aclose()
os.environ.clear()
os.environ.update(self._old_env)
async def test_status_without_probe(self):
resp = await self.client.get("/api/llm/status")
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(data["provider"], "openai_compat")
self.assertEqual(data["base_url"], "http://localhost:11434")
+87
View File
@@ -0,0 +1,87 @@
import unittest
import httpx
import json
from backend.openai_compat import _resolve_chat_completions_url, _resolve_models_url, query_model, list_models
class TestOpenAICompatUrl(unittest.TestCase):
def test_resolve_url_when_no_v1(self):
self.assertEqual(
_resolve_chat_completions_url("http://gpu:8000"),
"http://gpu:8000/v1/chat/completions",
)
def test_resolve_url_when_v1(self):
self.assertEqual(
_resolve_chat_completions_url("http://gpu:8000/v1"),
"http://gpu:8000/v1/chat/completions",
)
def test_resolve_url_when_v1_with_trailing_slash(self):
self.assertEqual(
_resolve_chat_completions_url("http://gpu:8000/v1/"),
"http://gpu:8000/v1/chat/completions",
)
def test_resolve_models_url(self):
self.assertEqual(
_resolve_models_url("http://gpu:8000"),
"http://gpu:8000/v1/models",
)
class TestOpenAICompatRequest(unittest.IsolatedAsyncioTestCase):
async def test_query_model_builds_payload_and_parses_response(self):
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["url"] = str(request.url)
captured["auth"] = request.headers.get("authorization")
captured["json"] = json.loads(request.content.decode("utf-8"))
return httpx.Response(
200,
json={
"choices": [
{
"message": {"content": "hello", "reasoning_details": None},
}
]
},
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, timeout=10.0) as client:
out = await query_model(
"my-model",
[{"role": "user", "content": "hi"}],
base_url="http://gpu:8000",
api_key="secret",
max_tokens=123,
timeout=10.0,
client=client,
)
self.assertEqual(captured["url"], "http://gpu:8000/v1/chat/completions")
self.assertEqual(captured["auth"], "Bearer secret")
self.assertEqual(captured["json"]["model"], "my-model")
self.assertEqual(captured["json"]["max_tokens"], 123)
self.assertEqual(out["content"], "hello")
async def test_list_models_parses_ids(self):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={"data": [{"id": "a"}, {"id": "b"}, {"nope": "c"}]},
)
transport = httpx.MockTransport(handler)
async with httpx.AsyncClient(transport=transport, timeout=10.0) as client:
ids = await list_models(
base_url="http://gpu:8000",
client=client,
)
self.assertEqual(ids, ["a", "b"])