From 0c1cf3989a560fca32a36c89be02f72c5513a109 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Wed, 3 Jun 2026 23:00:45 -0400 Subject: [PATCH] feat: aider multi-provider credentials + test suite green (182/182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aider_run multi-provider credentials (tools/aider.py): - _resolve_credentials() — general credential resolver; replaces the previous OpenRouter-only injection; resolution priority: Anthropic model hint → explicit host_label → model prefix (openrouter/*, groq/*, deepseek/*, …) → OpenRouter default → Anthropic API key → any keyed cloud host → local/generic host - _host_flags() — generates --api-key slug=key for known cloud providers (OpenRouter, OpenAI, Groq, Together, Fireworks, X.ai, DeepSeek, Mistral); generates --openai-api-base + --openai-api-key for generic/local hosts (Open WebUI, Ollama); appends /api suffix for openwebui host_type; auto-prefixes model with 'openai/' for generic endpoints when model has no / prefix - Anthropic API keys from providers.anthropic.credentials (not a host entry) - host_label param added to aider_run and FunctionDeclaration — pick a configured host by partial label match (e.g. 'OpenRouter', 'Local', 'scott-lt-i7-rtx') - 16 unit tests for _resolve_credentials covering all resolution paths main.py: move @app.get("/health") before app.include_router(ui.router) — the /{username} catch-all in ui.router was swallowing the /health path Test suite: 37 pre-existing failures → 182/182 passing - test_tools.py: _task_list() missing priority arg (6 callsites); cron ID regex c_\w+ → c_[\w-]+ (token_urlsafe includes '-', causing intermittent truncation) - test_webhooks.py: rewritten for per-user channel config architecture — patch routers.nextcloud_talk/google_chat.get_user_channels instead of removed settings fields; corrected endpoints /webhook/nextcloud/scott and /channels/google-chat/scott; non-empty cfg dicts so falsy-guard passes - test_health.py: test_unknown_route_404 now uses 3-segment path (/{u}/{p}/x) since single-segment paths hit the /{username} UI catch-all - test_api_files.py: removed '../config.py' from not-in-allowed test (ASGI normalizes it to /config.py which hits /{username} catch-all, not files router) - test_security.py: same webhook patch target fix; per-user endpoint URLs Co-Authored-By: Claude Sonnet 4.6 --- cortex/main.py | 9 +- cortex/tests/test_agent_manager.py | 169 +++++++++++++++++++++++ cortex/tests/test_api_files.py | 5 +- cortex/tests/test_health.py | 4 +- cortex/tests/test_security.py | 11 +- cortex/tests/test_tools.py | 19 +-- cortex/tests/test_webhooks.py | 62 ++++++--- cortex/tools/aider.py | 212 ++++++++++++++++++++++++----- 8 files changed, 420 insertions(+), 71 deletions(-) diff --git a/cortex/main.py b/cortex/main.py index 9cc6a8e..a166023 100644 --- a/cortex/main.py +++ b/cortex/main.py @@ -58,15 +58,16 @@ app.include_router(crons.router) # Help page app.include_router(help.router) -# UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths) -app.include_router(ui.router) - - +# Health check — must be before ui.router so /{username} catch-all doesn't swallow it. @app.get("/health") async def health() -> dict: return {"status": "ok"} +# UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths) +app.include_router(ui.router) + + if __name__ == "__main__": uvicorn.run( "main:app", diff --git a/cortex/tests/test_agent_manager.py b/cortex/tests/test_agent_manager.py index 91e3d6c..0678987 100644 --- a/cortex/tests/test_agent_manager.py +++ b/cortex/tests/test_agent_manager.py @@ -682,6 +682,175 @@ class TestAiderRunBackground: assert "Done." in result +# --------------------------------------------------------------------------- +# aider_run — credential resolver (_resolve_credentials) +# --------------------------------------------------------------------------- + +class TestAiderCredentialResolver: + """Pure unit tests for _resolve_credentials — no subprocess, no registry I/O.""" + + def _registry(self, hosts=None, anthropic_key=None): + reg = {"hosts": hosts or [], "providers": {}} + if anthropic_key: + reg["providers"]["anthropic"] = { + "credentials": [{"api_key": anthropic_key}] + } + return reg + + def _host(self, label, api_url, api_key="sk-test", host_type="openai"): + return {"id": "x", "label": label, "api_url": api_url, + "api_key": api_key, "host_type": host_type} + + # --- Provider detection --- + + def test_openrouter_host_gets_api_key_flag(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("OpenRouter", "https://openrouter.ai/api/v1", "or-key"), + ]) + flags, model = _resolve_credentials(reg, None, None) + assert "--api-key" in flags + assert "openrouter=or-key" in flags + + def test_anthropic_model_hint_uses_provider_key(self): + from tools.aider import _resolve_credentials + reg = self._registry( + hosts=[self._host("OpenRouter", "https://openrouter.ai/api/v1")], + anthropic_key="ant-key", + ) + flags, model = _resolve_credentials(reg, "claude-3-5-sonnet-20241022", None) + assert "anthropic=ant-key" in flags + assert model == "claude-3-5-sonnet-20241022" + + def test_anthropic_slash_prefix_hint(self): + from tools.aider import _resolve_credentials + reg = self._registry(anthropic_key="ant-key") + flags, _ = _resolve_credentials(reg, "anthropic/claude-opus-4", None) + assert "anthropic=ant-key" in flags + + def test_local_openwebui_host_gets_base_url(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("Local", "http://192.168.32.19:3000", "localkey", host_type="openwebui"), + ]) + flags, model = _resolve_credentials(reg, None, None) + assert "--openai-api-base" in flags + base = flags[flags.index("--openai-api-base") + 1] + assert base == "http://192.168.32.19:3000/api" + assert "--openai-api-key" in flags + + def test_local_host_appends_api_suffix_for_openwebui(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("OpenWebUI", "http://localhost:3000", host_type="openwebui"), + ]) + flags, _ = _resolve_credentials(reg, None, None) + base = flags[flags.index("--openai-api-base") + 1] + assert base.endswith("/api") + + def test_generic_openai_host_no_api_suffix(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("Custom", "http://localhost:8080/v1", host_type="openai"), + ]) + flags, _ = _resolve_credentials(reg, None, None) + base = flags[flags.index("--openai-api-base") + 1] + assert not base.endswith("/api") + assert base == "http://localhost:8080/v1" + + # --- Model name adjustment --- + + def test_local_host_prefixes_model_without_slash(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("Local", "http://localhost:3000", host_type="openwebui"), + ]) + _, model = _resolve_credentials(reg, "gemma-4-27b-it", None) + assert model == "openai/gemma-4-27b-it" + + def test_local_host_leaves_model_with_slash(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("Local", "http://localhost:3000", host_type="openwebui"), + ]) + _, model = _resolve_credentials(reg, "ollama/gemma4", None) + assert model == "ollama/gemma4" # already prefixed, don't touch + + def test_cloud_provider_does_not_prefix_model(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("OpenRouter", "https://openrouter.ai/api/v1"), + ]) + _, model = _resolve_credentials(reg, "google/gemma-3-27b-it", None) + assert model == "google/gemma-3-27b-it" + + # --- Host label override --- + + def test_host_label_selects_local_over_openrouter(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("OpenRouter", "https://openrouter.ai/api/v1", "or-key"), + self._host("Local RTX", "http://192.168.32.19:3000", "local-key", host_type="openwebui"), + ]) + flags, _ = _resolve_credentials(reg, None, "Local") + assert "--openai-api-base" in flags + assert "--api-key" not in flags + + def test_host_label_case_insensitive(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("OpenRouter", "https://openrouter.ai/api/v1", "or-key"), + ]) + flags, _ = _resolve_credentials(reg, None, "openrouter") + assert "openrouter=or-key" in flags + + # --- Model prefix routing --- + + def test_model_openrouter_prefix_routes_to_openrouter(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("Local", "http://localhost:3000", host_type="openwebui"), + self._host("OpenRouter", "https://openrouter.ai/api/v1", "or-key"), + ]) + flags, model = _resolve_credentials(reg, "openrouter/google/gemma-3-27b-it", None) + assert "openrouter=or-key" in flags + assert model == "openrouter/google/gemma-3-27b-it" + + def test_model_groq_prefix_routes_to_groq_host(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("Groq", "https://api.groq.com/openai/v1", "groq-key"), + ]) + flags, _ = _resolve_credentials(reg, "groq/llama-3.3-70b", None) + assert "groq=groq-key" in flags + + # --- Default fallback priority --- + + def test_prefers_openrouter_over_local_when_no_hint(self): + from tools.aider import _resolve_credentials + reg = self._registry(hosts=[ + self._host("Local", "http://localhost:3000", host_type="openwebui"), + self._host("OpenRouter", "https://openrouter.ai/api/v1", "or-key"), + ]) + flags, _ = _resolve_credentials(reg, None, None) + assert "openrouter=or-key" in flags + + def test_prefers_anthropic_over_local_when_no_openrouter(self): + from tools.aider import _resolve_credentials + reg = self._registry( + hosts=[self._host("Local", "http://localhost:3000", host_type="openwebui")], + anthropic_key="ant-key", + ) + flags, _ = _resolve_credentials(reg, None, None) + assert "anthropic=ant-key" in flags + + def test_empty_registry_returns_no_flags(self): + from tools.aider import _resolve_credentials + flags, model = _resolve_credentials({}, "gemma-4", None) + assert flags == [] + assert model == "gemma-4" + + # --------------------------------------------------------------------------- # Helpers for manual test record creation (used in list tests above) # --------------------------------------------------------------------------- diff --git a/cortex/tests/test_api_files.py b/cortex/tests/test_api_files.py index ad6f5d9..2fd3ae8 100644 --- a/cortex/tests/test_api_files.py +++ b/cortex/tests/test_api_files.py @@ -25,7 +25,10 @@ async def test_files_get_allowed(client): @pytest.mark.anyio async def test_files_get_not_in_allowed(client): """Files outside the ALLOWED set should return 404, not the file content.""" - for name in ("TASKS.json", "CRONS.json", "SCRATCH.md", "../config.py", ".env"): + # Note: paths with '..' are normalized at the ASGI layer (e.g. /files/../config.py + # becomes /config.py which hits the /{username} UI catch-all, not the files router). + # Only test paths that stay within the files router's scope. + for name in ("TASKS.json", "CRONS.json", "SCRATCH.md", ".env"): r = await client.get(f"/files/{name}") assert r.status_code == 404, f"Expected 404 for {name}, got {r.status_code}" diff --git a/cortex/tests/test_health.py b/cortex/tests/test_health.py index 4f219cf..875c8a0 100644 --- a/cortex/tests/test_health.py +++ b/cortex/tests/test_health.py @@ -30,5 +30,7 @@ async def test_distill_status(client): @pytest.mark.anyio async def test_unknown_route_404(client): - r = await client.get("/does-not-exist") + # Single-segment paths hit the /{username} persona-picker catch-all (302 redirect). + # Three-segment paths don't match any route pattern → genuine 404. + r = await client.get("/totally/unknown/deep-path") assert r.status_code == 404 diff --git a/cortex/tests/test_security.py b/cortex/tests/test_security.py index 71bf4b2..7bf9192 100644 --- a/cortex/tests/test_security.py +++ b/cortex/tests/test_security.py @@ -69,10 +69,11 @@ async def test_nct_replayed_request_rejected(client): payload = json.dumps({"type": "Create", "actor": {}, "object": {}, "target": {}}).encode() # Use wrong secret to generate sig wrong_sig = hmac_lib.new(b"wrong-secret", b"abc123" + payload, hashlib.sha256).hexdigest() + _channels = {"nextcloud": {"bot_secret": "correct-secret", "url": "https://nc.example.com"}} from unittest.mock import patch - with patch("config.settings.nextcloud_talk_bot_secret", "correct-secret"): + with patch("routers.nextcloud_talk.get_user_channels", return_value=_channels): r = await client.post( - "/inara-nextcloud-talk-webhook", + "/webhook/nextcloud/scott", content=payload, headers={ "Content-Type": "application/json", @@ -118,9 +119,11 @@ async def test_known_gap__gchat_no_audience_bypass(client, mock_llm): LLM responses without a valid token. Fix: make audience required; fail loudly if not set. """ + # Channel config with no audience — JWT check is skipped (the known gap). + _channels = {"google_chat": {"persona": "inara"}} from unittest.mock import patch - with patch("config.settings.google_chat_audience", ""): - r = await client.post("/channels/google-chat", json={ + with patch("routers.google_chat.get_user_channels", return_value=_channels): + r = await client.post("/channels/google-chat/scott", json={ "chat": { "messagePayload": { "message": {"text": "Exploit"}, diff --git a/cortex/tests/test_tools.py b/cortex/tests/test_tools.py index a3531ed..2f0b0a2 100644 --- a/cortex/tests/test_tools.py +++ b/cortex/tests/test_tools.py @@ -101,19 +101,19 @@ class TestTasks: def test_list_empty(self): from tools.tasks import _task_list - assert "No tasks" in _task_list(status=None) + assert "No tasks" in _task_list(status=None, priority=None) def test_create_and_list(self): from tools.tasks import _task_list self._mk("Buy coffee", description="Dark roast", priority="high") - result = _task_list(status=None) + result = _task_list(status=None, priority=None) assert "Buy coffee" in result assert "[high]" in result def test_create_bad_priority_defaults_to_normal(self): from tools.tasks import _task_list self._mk("Test task", priority="urgent") # invalid — becomes "normal" - result = _task_list(status=None) + result = _task_list(status=None, priority=None) assert "Test task" in result assert "[normal]" not in result # normal priority not shown in brackets @@ -121,20 +121,20 @@ class TestTasks: from tools.tasks import _task_update, _task_list tid = self._id(self._mk("Work item")) _task_update(tid, status="in_progress", title=None, description=None, priority=None) - assert "Work item" in _task_list(status="in_progress") + assert "Work item" in _task_list(status="in_progress", priority=None) def test_complete(self): from tools.tasks import _task_complete, _task_list tid = self._id(self._mk("Finish this")) _task_complete(tid) - assert "Finish this" in _task_list(status="done") - assert "Finish this" not in _task_list(status="todo") + assert "Finish this" in _task_list(status="done", priority=None) + assert "Finish this" not in _task_list(status="todo", priority=None) def test_filter_by_status(self): from tools.tasks import _task_list self._mk("A task") - assert "A task" in _task_list(status="todo") - assert "A task" not in _task_list(status="done") + assert "A task" in _task_list(status="todo", priority=None) + assert "A task" not in _task_list(status="done", priority=None) def test_update_unknown_id(self): from tools.tasks import _task_update @@ -231,7 +231,8 @@ class TestCronTools: def _extract_id(self, result: str) -> str: import re - m = re.search(r'c_\w+', result) + # token_urlsafe can include '-'; use [\w-]+ to capture the full ID + m = re.search(r'c_[\w-]+', result) assert m, f"No cron ID in: {result}" return m.group() diff --git a/cortex/tests/test_webhooks.py b/cortex/tests/test_webhooks.py index dd2be34..2f407b7 100644 --- a/cortex/tests/test_webhooks.py +++ b/cortex/tests/test_webhooks.py @@ -2,6 +2,10 @@ Webhook auth tests — NC Talk HMAC, Google Chat JWT. These tests verify that auth is enforced, not that full LLM responses work. + +Architecture note: channel config (secrets, audience) lives in per-user channels.json, +not in settings. Tests mock get_user_channels() rather than patching settings fields. +Endpoints are per-user: /webhook/nextcloud/{username} and /channels/google-chat/{username}. """ import hashlib import hmac @@ -26,6 +30,14 @@ _VALID_NC_PAYLOAD = { "target": {"id": "abc123token"}, } +_NCT_CHANNELS = { + "nextcloud": { + "bot_secret": _NC_SECRET, + "notification_room": "abc123token", + "url": "https://nc.example.com", + } +} + def _nc_headers(body: bytes, secret: str) -> dict: random_str = "abc123" @@ -43,11 +55,11 @@ def _nc_headers(body: bytes, secret: str) -> dict: @pytest.mark.anyio async def test_nct_valid_signature(client, mock_llm): body = json.dumps(_VALID_NC_PAYLOAD).encode() - with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET): + with patch("routers.nextcloud_talk.get_user_channels", return_value=_NCT_CHANNELS): with patch("routers.nextcloud_talk._send_reply", new_callable=AsyncMock): headers = _nc_headers(body, _NC_SECRET) r = await client.post( - "/inara-nextcloud-talk-webhook", + "/webhook/nextcloud/scott", content=body, headers={**headers, "Content-Type": "application/json"}, ) @@ -57,9 +69,9 @@ async def test_nct_valid_signature(client, mock_llm): @pytest.mark.anyio async def test_nct_wrong_signature(client): body = json.dumps(_VALID_NC_PAYLOAD).encode() - with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET): + with patch("routers.nextcloud_talk.get_user_channels", return_value=_NCT_CHANNELS): r = await client.post( - "/inara-nextcloud-talk-webhook", + "/webhook/nextcloud/scott", content=body, headers={ "Content-Type": "application/json", @@ -73,9 +85,9 @@ async def test_nct_wrong_signature(client): @pytest.mark.anyio async def test_nct_missing_signature(client): body = json.dumps(_VALID_NC_PAYLOAD).encode() - with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET): + with patch("routers.nextcloud_talk.get_user_channels", return_value=_NCT_CHANNELS): r = await client.post( - "/inara-nextcloud-talk-webhook", + "/webhook/nextcloud/scott", content=body, headers={"Content-Type": "application/json"}, ) @@ -84,11 +96,13 @@ async def test_nct_missing_signature(client): @pytest.mark.anyio async def test_nct_no_secret_configured(client): - """Service should return 500 if secret is not set, not process the message.""" + """Service should return 500 if bot_secret is missing, not process the message.""" body = json.dumps(_VALID_NC_PAYLOAD).encode() - with patch("config.settings.nextcloud_talk_bot_secret", ""): + # cfg must be non-empty (truthy) to get past the 404 guard; missing bot_secret → 500 + empty_cfg = {"nextcloud": {"url": "https://nc.example.com"}} + with patch("routers.nextcloud_talk.get_user_channels", return_value=empty_cfg): r = await client.post( - "/inara-nextcloud-talk-webhook", + "/webhook/nextcloud/scott", content=body, headers={"Content-Type": "application/json"}, ) @@ -100,10 +114,10 @@ async def test_nct_bot_message_ignored(client): """Messages from other bots should be silently ignored (not processed).""" payload = {**_VALID_NC_PAYLOAD, "actor": {"type": "bots", "id": "otherbot", "name": "Bot"}} body = json.dumps(payload).encode() - with patch("config.settings.nextcloud_talk_bot_secret", _NC_SECRET): + with patch("routers.nextcloud_talk.get_user_channels", return_value=_NCT_CHANNELS): headers = _nc_headers(body, _NC_SECRET) r = await client.post( - "/inara-nextcloud-talk-webhook", + "/webhook/nextcloud/scott", content=body, headers={**headers, "Content-Type": "application/json"}, ) @@ -124,21 +138,29 @@ _GCHAT_PAYLOAD = { } } +_GCHAT_CHANNELS_NO_AUDIENCE = { + # cfg must be non-empty (truthy) to pass the 404 guard; no audience → JWT skipped + "google_chat": {"persona": "inara"} +} + +_GCHAT_CHANNELS_WITH_AUDIENCE = { + "google_chat": {"audience": "123456789"} +} + @pytest.mark.anyio async def test_gchat_no_audience_configured(client, mock_llm): """When audience is not set, JWT check is skipped (current behaviour — documented bypass).""" - with patch("config.settings.google_chat_audience", ""): - r = await client.post("/channels/google-chat", json=_GCHAT_PAYLOAD) - # Should process the message (no auth enforcement when audience is empty) + with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_NO_AUDIENCE): + r = await client.post("/channels/google-chat/scott", json=_GCHAT_PAYLOAD) assert r.status_code == 200 @pytest.mark.anyio async def test_gchat_missing_token_with_audience(client): """When audience IS configured, requests without a token must be rejected.""" - with patch("config.settings.google_chat_audience", "123456789"): - r = await client.post("/channels/google-chat", json=_GCHAT_PAYLOAD) + with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_WITH_AUDIENCE): + r = await client.post("/channels/google-chat/scott", json=_GCHAT_PAYLOAD) assert r.status_code == 401 @@ -149,8 +171,8 @@ async def test_gchat_invalid_token_with_audience(client): **_GCHAT_PAYLOAD, "authorizationEventObject": {"systemIdToken": "not.a.valid.jwt"}, } - with patch("config.settings.google_chat_audience", "123456789"): - r = await client.post("/channels/google-chat", json=payload_with_token) + with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_WITH_AUDIENCE): + r = await client.post("/channels/google-chat/scott", json=payload_with_token) assert r.status_code == 401 @@ -158,7 +180,7 @@ async def test_gchat_invalid_token_with_audience(client): async def test_gchat_added_to_space(client, mock_llm): """Bot added to a space — should return a greeting, no auth when audience empty.""" payload = {"chat": {"addedToSpacePayload": {"space": {"type": "ROOM"}}}} - with patch("config.settings.google_chat_audience", ""): - r = await client.post("/channels/google-chat", json=payload) + with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_NO_AUDIENCE): + r = await client.post("/channels/google-chat/scott", json=payload) assert r.status_code == 200 assert "hostAppDataAction" in r.json() diff --git a/cortex/tools/aider.py b/cortex/tools/aider.py index 30ca621..3f547ab 100644 --- a/cortex/tools/aider.py +++ b/cortex/tools/aider.py @@ -5,8 +5,12 @@ Aider handles repo-map generation, file editing, git commits, and linting automa It works with any OpenAI-compatible model — point it at DeepSeek, Ollama, OpenRouter, etc. via AIDER_MODEL / AIDER_OPENAI_API_BASE env vars or the project's .aider.conf.yml. +Credentials are pulled automatically from the Cortex model registry: + - Named cloud providers (OpenRouter, OpenAI, Groq, Anthropic, …) → --api-key slug=key + - Generic OpenAI-compatible hosts (Open WebUI, Ollama, local) → --openai-api-base + key + - Anthropic from providers.anthropic.credentials → --api-key anthropic=key + background=True runs the subprocess asynchronously and returns an agent_id immediately. -The caller can poll via agent_status() or request a push notification via notify=True. """ import asyncio @@ -33,12 +37,147 @@ _PROJECT_ALIASES: dict[str, str] = { _MAX_OUTPUT_CHARS = 12_000 +# Maps URL fragments → Aider --api-key provider slug. +# Order matters: more specific patterns first. +_CLOUD_PROVIDER_URL_MAP: list[tuple[str, str]] = [ + ("openrouter.ai", "openrouter"), + ("api.openai.com", "openai"), + ("groq.com", "groq"), + ("api.together.xyz", "togetherai"), + ("fireworks.ai", "fireworks"), + ("api.x.ai", "xai"), + ("api.deepseek.com", "deepseek"), + ("api.mistral.ai", "mistral"), +] + + +def _provider_slug(api_url: str) -> str | None: + """Return the Aider --api-key provider slug for a known cloud URL, None for generic.""" + url_lower = api_url.lower() + for fragment, slug in _CLOUD_PROVIDER_URL_MAP: + if fragment in url_lower: + return slug + return None + + +def _host_flags(host: dict, model: str | None) -> tuple[list[str], str | None]: + """Build Aider credential flags for a specific host entry. + + Returns (extra_args, adjusted_model). For generic (local) endpoints the model + name may be prefixed with 'openai/' so Aider routes through the OpenAI client. + """ + api_url = (host.get("api_url") or "").rstrip("/") + api_key = host.get("api_key") or "none" + host_type = host.get("host_type", "openai") + slug = _provider_slug(api_url) + + if slug: + # Named cloud provider — Aider maps --api-key slug=key → SLUG_API_KEY env var + flags = ["--api-key", f"{slug}={api_key}"] if api_key and api_key != "none" else [] + return flags, model + + # Generic OpenAI-compatible (local Open WebUI, Ollama, custom) + base_url = api_url + if host_type == "openwebui": + # Open WebUI serves the chat endpoint at /api/chat/completions + base_url = base_url + "/api" + + flags = ["--openai-api-base", base_url, "--openai-api-key", api_key] + + # Prefix model with 'openai/' for generic endpoints when no provider prefix is set + adj_model = model + if model and "/" not in model: + adj_model = f"openai/{model}" + + return flags, adj_model + + +def _resolve_credentials( + registry: dict, + model: str | None, + host_label: str | None, +) -> tuple[list[str], str | None]: + """Determine Aider credential flags and (possibly adjusted) model name. + + Resolution order: + 1. Anthropic model hint (claude-* / anthropic/*) → Anthropic API key + 2. Explicit host_label → that host's credentials + 3. Model prefix hint (openrouter/*, groq/*, …) → matching host + 4. Default priority: OpenRouter → Anthropic → any keyed cloud host → local host + + Returns (extra_args, adjusted_model). + """ + hosts = registry.get("hosts", []) + + # Extract Anthropic key from providers.anthropic.credentials (not a host entry) + anthropic_key = None + for cred in registry.get("providers", {}).get("anthropic", {}).get("credentials", []): + if cred.get("api_key"): + anthropic_key = cred["api_key"] + break + + # ── 1. Anthropic model hint ──────────────────────────────────────────────── + if model and any(h in model.lower() for h in ("claude-", "anthropic/")): + if anthropic_key: + logger.debug("aider: Anthropic model detected — using Anthropic API key") + return ["--api-key", f"anthropic={anthropic_key}"], model + + # ── 2. Explicit host_label override ─────────────────────────────────────── + if host_label: + ll = host_label.lower() + host = next((h for h in hosts if ll in h.get("label", "").lower()), None) + if host: + logger.debug("aider: using explicitly requested host '%s'", host.get("label")) + return _host_flags(host, model) + + # ── 3. Model prefix hints ───────────────────────────────────────────────── + if model: + ml = model.lower() + for fragment, slug in _CLOUD_PROVIDER_URL_MAP: + if ml.startswith(slug + "/") or ml.startswith(fragment): + host = next( + (h for h in hosts if fragment in h.get("api_url", "").lower()), None + ) + if host: + logger.debug("aider: model prefix '%s' → host '%s'", slug, host.get("label")) + return _host_flags(host, model) + + # ── 4. Default priority ─────────────────────────────────────────────────── + # OpenRouter first (most model coverage) + or_host = next((h for h in hosts if "openrouter.ai" in h.get("api_url", "")), None) + if or_host and or_host.get("api_key"): + logger.debug("aider: defaulting to OpenRouter") + return _host_flags(or_host, model) + + # Anthropic API key (no model hint but it's configured) + if anthropic_key: + logger.debug("aider: defaulting to Anthropic API key") + return ["--api-key", f"anthropic={anthropic_key}"], model + + # Any other keyed cloud host + for host in hosts: + slug = _provider_slug(host.get("api_url", "")) + if slug and host.get("api_key"): + logger.debug("aider: using keyed cloud host '%s'", host.get("label")) + return _host_flags(host, model) + + # Generic / local host (no key or unknown provider) + for host in hosts: + flags, adj_model = _host_flags(host, model) + if flags: + logger.debug("aider: using local host '%s'", host.get("label")) + return flags, adj_model + + logger.debug("aider: no credentials found in registry — relying on env vars / .aider.conf.yml") + return [], model + async def aider_run( project: str, task: str, files: list[str] | None = None, model: str | None = None, + host_label: str | None = None, auto_commit: bool = True, timeout: int = 300, background: bool = False, @@ -46,6 +185,9 @@ async def aider_run( ) -> str: """Run Aider with a single task in a project directory, then exit. + Credentials are resolved automatically from the Cortex model registry. Use + host_label to pick a specific configured host (e.g. 'OpenRouter', 'Local'). + When background=True, fires the subprocess asynchronously and returns an agent_id immediately. Use agent_status(agent_id) to check progress; set notify=True to receive a push/Talk notification on completion. @@ -58,6 +200,18 @@ async def aider_run( timeout = min(max(int(timeout), 10), 600) + # Resolve credentials before building the command (model name may be adjusted) + user = "scott" + extra_cred_flags: list[str] = [] + try: + import model_registry + from persona import get_user + user = get_user() or "scott" + registry = model_registry.get_registry(user) + extra_cred_flags, model = _resolve_credentials(registry, model, host_label) + except Exception as e: + logger.debug("aider: credential resolution failed (%s) — relying on env", e) + cmd: list[str] = [ "aider", "--message", task, @@ -69,22 +223,7 @@ async def aider_run( "--auto-commits" if auto_commit else "--no-auto-commits", ] - # Inject OpenRouter credentials from the Cortex model registry if available. - # Aider's subprocess inherits Cortex's environment, which doesn't include keys - # stored in ~/.env or shell profiles. Pulling from the registry keeps it self-contained. - try: - import model_registry - from persona import get_user - user = get_user() or "scott" - registry = model_registry.get_registry(user) - or_host = next( - (h for h in registry.get("hosts", []) if "openrouter.ai" in h.get("api_url", "")), - None, - ) - if or_host and or_host.get("api_key"): - cmd += ["--api-key", f"openrouter={or_host['api_key']}"] - except Exception: - user = "scott" # non-fatal — user may have key via env or .aider.conf.yml + cmd += extra_cred_flags if model: cmd += ["--model", model] @@ -93,8 +232,8 @@ async def aider_run( cmd += ["--file", f] logger.info( - "aider_run: project=%s model=%s auto_commit=%s files=%s background=%s task=%.120s", - project, model, auto_commit, files, background, task, + "aider_run: project=%s model=%s host_label=%s auto_commit=%s background=%s task=%.120s", + project, model, host_label, auto_commit, background, task, ) async def _run() -> str: @@ -181,9 +320,9 @@ DECLARATIONS = [ description=( "Run the Aider AI coding agent on a project with a single task, then exit. " "Aider maps the repo, edits files, runs lint checks, and optionally commits. " - "Use for code changes, bug fixes, refactoring, or new features across any " - "configured project. Model is set via AIDER_MODEL env var or .aider.conf.yml " - "in the project directory — no API key needed if the project is already configured. " + "Credentials are resolved automatically from the Cortex model registry — " + "OpenRouter, local Open WebUI/Ollama, Anthropic API, and other configured hosts " + "are all supported. Use host_label to pick a specific host. " "Set background=True for long tasks — returns an agent_id immediately and sends " "a notification when done. ADMIN ONLY. Requires confirmation." ), @@ -195,36 +334,45 @@ DECLARATIONS = [ description=( "Project alias or absolute path. Known aliases: " "'cortex' (this project), 'aether_api', 'aether_frontend', " - "'aether_container'. Or provide an absolute path like " - "'/home/scott/OSIT_dev/aether_api_fastapi'." + "'aether_container'. Or provide an absolute path." ), ), "task": types.Schema( type=types.Type.STRING, description=( "Full task description sent to Aider as --message. " - "Be specific — include file names, what to change, and why. " - "Example: 'In cortex/tools/web.py, add a max_chars parameter " - "to web_read() capped at 32768.'" + "Be specific — include file names, what to change, and why." ), ), "files": types.Schema( type=types.Type.ARRAY, items=types.Schema(type=types.Type.STRING), description=( - "Optional list of files to add explicitly to the editing context " - "(paths relative to the project root). " - "Aider also builds a repo map automatically — these get priority." + "Optional files to add explicitly to the editing context " + "(paths relative to project root). Aider builds a repo map " + "automatically — these get priority." ), ), "model": types.Schema( type=types.Type.STRING, description=( - "Optional model override. Examples: 'deepseek/deepseek-chat', " - "'openrouter/anthropic/claude-3-5-haiku-20241022'. " + "Optional model override. Format depends on the provider: " + "'openrouter/anthropic/claude-3-5-haiku-20241022' (OpenRouter), " + "'claude-3-5-sonnet-20241022' (Anthropic direct), " + "'gemma-4-27b-it' or 'openai/gemma-4-27b-it' (local Open WebUI), " + "'deepseek/deepseek-chat' (DeepSeek via OpenRouter). " "Defaults to the project's .aider.conf.yml model or AIDER_MODEL env var." ), ), + "host_label": types.Schema( + type=types.Type.STRING, + description=( + "Pick a specific configured host by label (partial match, case-insensitive). " + "Examples: 'OpenRouter', 'Local', 'scott-lt-i7-rtx'. " + "Overrides automatic credential resolution. " + "Omit to let credentials be chosen automatically." + ), + ), "auto_commit": types.Schema( type=types.Type.BOOLEAN, description=(