From 8075442200006f11b00662377608377679eb2e2f Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Thu, 2 Apr 2026 01:01:58 -0700 Subject: [PATCH] fix: OpenRouter model routing regression + project input validation - resolve_model_provider: fix regression where OpenRouter model IDs like openai/gpt-5.4-mini had their prefix stripped, causing AIAgent to look for OPENAI_API_KEY (direct API) instead of routing through OpenRouter. All chats returned Connection lost for OpenRouter users. Fix: only strip prefix and use direct-API when config.provider explicitly matches that provider; pass full provider/model string through for openrouter. - Project name: cap at 128 chars, reject empty after strip on create/rename - Project color: validate ^#[0-9a-fA-F]{3,8}$ to prevent CSS injection via dot.style.background in sessions.js - Remove 2 redundant sys.path.insert() calls in cron handlers Tests: 214 passed, 23 pre-existing failures, 0 regressions. Co-Authored-By: Claude Opus 4.6 (1M context) --- api/config.py | 10 ++++++---- api/routes.py | 19 ++++++++++++++----- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/api/config.py b/api/config.py index 198ab3e..977923a 100644 --- a/api/config.py +++ b/api/config.py @@ -343,12 +343,14 @@ def resolve_model_provider(model_id: str): if '/' in model_id: prefix, bare = model_id.split('/', 1) - # If prefix matches config provider, strip it + # If prefix matches config provider, strip it and use that provider directly if config_provider and prefix == config_provider: return bare, config_provider, config_base_url - # If prefix is a known direct-API provider, use it - # (base_url only applies when matching config provider) - if prefix in _PROVIDER_MODELS: + # If the config provider is openrouter (or unset/None), pass the full + # provider/model string through -- OpenRouter uses this as its model ID. + # Only strip the prefix and switch to a direct-API provider when the + # config is explicitly set to that direct provider. + if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS: return bare, prefix, None return model_id, config_provider, config_base_url diff --git a/api/routes.py b/api/routes.py index 255aa6e..ee7ccea 100644 --- a/api/routes.py +++ b/api/routes.py @@ -140,7 +140,6 @@ def handle_get(handler, parsed): # ── Cron API (GET) ── if parsed.path == '/api/crons': - sys.path.insert(0, str(Path(__file__).parent.parent)) from cron.jobs import list_jobs return j(handler, {'jobs': list_jobs(include_disabled=True)}) @@ -345,8 +344,14 @@ def handle_post(handler, parsed): if parsed.path == '/api/projects/create': try: require(body, 'name') except ValueError as e: return bad(handler, str(e)) + import re as _re + name = body['name'].strip()[:128] + if not name: return bad(handler, 'name required') + color = body.get('color') + if color and not _re.match(r'^#[0-9a-fA-F]{3,8}$', color): + return bad(handler, 'Invalid color format') projects = load_projects() - proj = {'project_id': uuid.uuid4().hex[:12], 'name': body['name'], 'color': body.get('color'), 'created_at': time.time()} + proj = {'project_id': uuid.uuid4().hex[:12], 'name': name, 'color': color, 'created_at': time.time()} projects.append(proj) save_projects(projects) return j(handler, {'ok': True, 'project': proj}) @@ -354,11 +359,16 @@ def handle_post(handler, parsed): if parsed.path == '/api/projects/rename': try: require(body, 'project_id', 'name') except ValueError as e: return bad(handler, str(e)) + import re as _re projects = load_projects() proj = next((p for p in projects if p['project_id'] == body['project_id']), None) if not proj: return bad(handler, 'Project not found', 404) - proj['name'] = body['name'] - if 'color' in body: proj['color'] = body['color'] + proj['name'] = body['name'].strip()[:128] + if 'color' in body: + color = body['color'] + if color and not _re.match(r'^#[0-9a-fA-F]{3,8}$', color): + return bad(handler, 'Invalid color format') + proj['color'] = color save_projects(projects) return j(handler, {'ok': True, 'project': proj}) @@ -594,7 +604,6 @@ def _handle_cron_recent(handler, parsed): qs = parse_qs(parsed.query) since = float(qs.get('since', ['0'])[0]) try: - sys.path.insert(0, str(Path(__file__).parent.parent)) from cron.jobs import list_jobs jobs = list_jobs(include_disabled=True) completions = []