Archived
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
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:
@@ -0,0 +1 @@
|
||||
"""LLM Council backend package."""
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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("..."))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user