Skip to content

Commit 0afc892

Browse files
committed
feat: dynamic model discovery from OpenRouter + React Doctor 100/100
Backend: Models are now dynamically fetched from OpenRouter API (filtered by programming-relevant providers), cached in Redis (1h TTL) + in-memory (30min), with periodic background refresh. Auto-detects latest Anthropic Sonnet as default. Falls back to hardcoded list if API is unavailable. Frontend: Model selector fetches models from /api/models/available endpoint. Refactored hero-section and message-input to useReducer (React Doctor). Replaced client-side router.replace() redirect with render-time redirect().
1 parent e86a399 commit 0afc892

File tree

14 files changed

+1124
-1149
lines changed

14 files changed

+1124
-1149
lines changed

README.md

Lines changed: 442 additions & 829 deletions
Large diffs are not rendered by default.

backend/agent/api.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from utils.encryption import decrypt_data
3030
from utils.file_utils import is_image_file
3131
from utils.logger import logger, structlog
32-
from utils.models import get_available_models, get_default_model, resolve_model_id
32+
from utils.models import get_default_model_id, resolve_model_id
3333
from utils.rate_limit import limiter
3434

3535
from .utils import check_for_active_project_agent_run
@@ -89,7 +89,7 @@ def chunk_responses(responses: list, chunk_size: int = SSE_BATCH_SIZE) -> list:
8989

9090

9191
class AgentStartRequest(BaseModel):
92-
model_name: str | None = None # Will be set from config.MODEL_TO_USE in the endpoint
92+
model_name: str | None = None # Defaults to latest Anthropic Sonnet if not specified
9393
enable_thinking: bool | None = False
9494
reasoning_effort: str | None = "low"
9595
stream: bool | None = True
@@ -123,11 +123,15 @@ def initialize(_db: DBConnection, _instance_id: str | None = None):
123123
async def get_models_available():
124124
"""Get list of available AI models for user selection.
125125
126-
Returns models with their display names, providers, and descriptions.
126+
Models are dynamically fetched from OpenRouter and cached (1h TTL).
127+
Falls back to hardcoded list if the API is unavailable.
127128
"""
128-
models = get_available_models()
129-
default_model = get_default_model()
130-
return {"models": models, "default_model_id": default_model["id"] if default_model else "claude-sonnet-4.5"}
129+
from services.openrouter_models import get_available_models_cached
130+
131+
models = await get_available_models_cached()
132+
default_model = next((m for m in models if m.get("default")), None)
133+
default_id = default_model["id"] if default_model else (models[0]["id"] if models else get_default_model_id())
134+
return {"models": models, "default_model_id": default_id}
131135

132136

133137
async def cleanup():
@@ -402,7 +406,7 @@ async def start_agent(
402406

403407
# Model selection: request > project (single source of truth) > config default
404408
stored_model = project_data.get("model_name")
405-
model_name = body.model_name or stored_model or config.MODEL_TO_USE
409+
model_name = body.model_name or stored_model or get_default_model_id()
406410

407411
# Resolve to full OpenRouter ID for API calls
408412
model_name = resolve_model_id(model_name)
@@ -1138,8 +1142,8 @@ async def generate_and_update_project_name(project_id: str, prompt: str):
11381142
db_conn = DBConnection()
11391143
client = await db_conn.client
11401144

1141-
# Use the configured model and resolve to full OpenRouter ID
1142-
model_name = resolve_model_id(config.MODEL_TO_USE)
1145+
# Use the default model and resolve to full OpenRouter ID
1146+
model_name = resolve_model_id(get_default_model_id())
11431147
system_prompt = "You are a helpful assistant that generates extremely concise titles (2-4 words maximum) for chat threads based on the user's message."
11441148
user_message = f'Generate an extremely brief title (2-4 words only) for a chat thread that starts with this message: "{prompt}"'
11451149
messages = [{"role": "system", "content": system_prompt}, {"role": "user", "content": user_message}]
@@ -1179,7 +1183,7 @@ async def generate_and_update_project_name(project_id: str, prompt: str):
11791183
async def initiate_agent_with_files(
11801184
request: Request,
11811185
prompt: str = Form(...),
1182-
model_name: str | None = Form(None), # Default to None to use config.MODEL_TO_USE
1186+
model_name: str | None = Form(None), # Defaults to latest Anthropic Sonnet if not specified
11831187
enable_thinking: bool | None = Form(False),
11841188
reasoning_effort: str | None = Form("low"),
11851189
stream: bool | None = Form(True),
@@ -1205,9 +1209,9 @@ async def initiate_agent_with_files(
12051209
# Use model from config if not specified in the request
12061210
logger.info(f"Original model_name from request: {model_name}")
12071211

1208-
if model_name is None:
1209-
model_name = config.MODEL_TO_USE
1210-
logger.info(f"Using model from config: {model_name}")
1212+
if not model_name:
1213+
model_name = get_default_model_id()
1214+
logger.info(f"Using default model: {model_name}")
12111215

12121216
# Keep the original short model ID for UI persistence
12131217
ui_model_name = model_name

backend/inngest_functions/agent_run.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ async def process_agent_run(ctx: inngest.Context) -> dict:
112112
from services.supabase import DBConnection
113113
from utils.config import config
114114
from utils.encryption import decrypt_data
115+
from utils.models import get_default_model_id
115116
from utils.retry import retry
116117

117118
# Self-initialize: ensure Redis and DB are ready
@@ -127,7 +128,7 @@ async def process_agent_run(ctx: inngest.Context) -> dict:
127128
raise RuntimeError(f"Agent run {agent_run_id} not found in database")
128129

129130
metadata = run_result.data[0].get("metadata", {})
130-
model_name = metadata.get("model_name", config.MODEL_TO_USE)
131+
model_name = metadata.get("model_name") or get_default_model_id()
131132
enable_thinking = metadata.get("enable_thinking")
132133
reasoning_effort = metadata.get("reasoning_effort")
133134
enable_context_manager = metadata.get("enable_context_manager", False)

backend/main.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,32 @@ async def lifespan(_app: FastAPI):
6161
composio_api.initialize(db)
6262
composio_secure_mcp_api.initialize(db)
6363

64+
# Load dynamic models from OpenRouter (cached in Redis)
65+
from services.openrouter_models import refresh_models, start_periodic_refresh
66+
67+
try:
68+
await refresh_models()
69+
logger.info("Dynamic model list loaded from OpenRouter")
70+
except Exception as e:
71+
logger.warning(f"Failed to load dynamic models (using fallback): {e}")
72+
6473
# Start background health monitoring
6574
from utils.health_check import start_health_monitoring
6675

6776
health_task = asyncio.create_task(start_health_monitoring(instance_id, interval=600))
6877

78+
# Start periodic model refresh (every hour)
79+
model_refresh_task = asyncio.create_task(start_periodic_refresh())
80+
6981
yield
7082

71-
# Shutdown: cancel health monitoring
83+
# Shutdown: cancel background tasks
7284
health_task.cancel()
85+
model_refresh_task.cancel()
7386
with contextlib.suppress(asyncio.CancelledError):
7487
await health_task
88+
with contextlib.suppress(asyncio.CancelledError):
89+
await model_refresh_task
7590

7691
# Clean up agent resources
7792
logger.info("Cleaning up agent resources")

backend/services/llm.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,16 @@ def get_llm_router() -> Router | None:
128128
return _llm_router
129129

130130

131+
def refresh_router() -> None:
132+
"""Rebuild the Router with the current model store.
133+
134+
Called by services.openrouter_models after dynamic model refresh.
135+
"""
136+
global _llm_router
137+
_llm_router = create_llm_router()
138+
logger.info("LiteLLM Router rebuilt with refreshed model list")
139+
140+
131141
def get_router_model_name(model_name: str) -> str | None:
132142
"""Map an OpenRouter model ID to the router group name (short ID).
133143

0 commit comments

Comments
 (0)