feat: aider multi-provider credentials + test suite green (182/182)

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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-03 23:00:45 -04:00
parent 658c508925
commit 0c1cf3989a
8 changed files with 420 additions and 71 deletions

View File

@@ -58,15 +58,16 @@ app.include_router(crons.router)
# Help page # Help page
app.include_router(help.router) app.include_router(help.router)
# UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths) # Health check — must be before ui.router so /{username} catch-all doesn't swallow it.
app.include_router(ui.router)
@app.get("/health") @app.get("/health")
async def health() -> dict: async def health() -> dict:
return {"status": "ok"} return {"status": "ok"}
# UI router (login + /{user}/{persona} — must be last to avoid swallowing API paths)
app.include_router(ui.router)
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run( uvicorn.run(
"main:app", "main:app",

View File

@@ -682,6 +682,175 @@ class TestAiderRunBackground:
assert "Done." in result 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) # Helpers for manual test record creation (used in list tests above)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -25,7 +25,10 @@ async def test_files_get_allowed(client):
@pytest.mark.anyio @pytest.mark.anyio
async def test_files_get_not_in_allowed(client): async def test_files_get_not_in_allowed(client):
"""Files outside the ALLOWED set should return 404, not the file content.""" """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}") r = await client.get(f"/files/{name}")
assert r.status_code == 404, f"Expected 404 for {name}, got {r.status_code}" assert r.status_code == 404, f"Expected 404 for {name}, got {r.status_code}"

View File

@@ -30,5 +30,7 @@ async def test_distill_status(client):
@pytest.mark.anyio @pytest.mark.anyio
async def test_unknown_route_404(client): 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 assert r.status_code == 404

View File

@@ -69,10 +69,11 @@ async def test_nct_replayed_request_rejected(client):
payload = json.dumps({"type": "Create", "actor": {}, "object": {}, "target": {}}).encode() payload = json.dumps({"type": "Create", "actor": {}, "object": {}, "target": {}}).encode()
# Use wrong secret to generate sig # Use wrong secret to generate sig
wrong_sig = hmac_lib.new(b"wrong-secret", b"abc123" + payload, hashlib.sha256).hexdigest() 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 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( r = await client.post(
"/inara-nextcloud-talk-webhook", "/webhook/nextcloud/scott",
content=payload, content=payload,
headers={ headers={
"Content-Type": "application/json", "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. LLM responses without a valid token.
Fix: make audience required; fail loudly if not set. 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 from unittest.mock import patch
with patch("config.settings.google_chat_audience", ""): with patch("routers.google_chat.get_user_channels", return_value=_channels):
r = await client.post("/channels/google-chat", json={ r = await client.post("/channels/google-chat/scott", json={
"chat": { "chat": {
"messagePayload": { "messagePayload": {
"message": {"text": "Exploit"}, "message": {"text": "Exploit"},

View File

@@ -101,19 +101,19 @@ class TestTasks:
def test_list_empty(self): def test_list_empty(self):
from tools.tasks import _task_list 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): def test_create_and_list(self):
from tools.tasks import _task_list from tools.tasks import _task_list
self._mk("Buy coffee", description="Dark roast", priority="high") 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 "Buy coffee" in result
assert "[high]" in result assert "[high]" in result
def test_create_bad_priority_defaults_to_normal(self): def test_create_bad_priority_defaults_to_normal(self):
from tools.tasks import _task_list from tools.tasks import _task_list
self._mk("Test task", priority="urgent") # invalid — becomes "normal" 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 "Test task" in result
assert "[normal]" not in result # normal priority not shown in brackets 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 from tools.tasks import _task_update, _task_list
tid = self._id(self._mk("Work item")) tid = self._id(self._mk("Work item"))
_task_update(tid, status="in_progress", title=None, description=None, priority=None) _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): def test_complete(self):
from tools.tasks import _task_complete, _task_list from tools.tasks import _task_complete, _task_list
tid = self._id(self._mk("Finish this")) tid = self._id(self._mk("Finish this"))
_task_complete(tid) _task_complete(tid)
assert "Finish this" in _task_list(status="done") assert "Finish this" in _task_list(status="done", priority=None)
assert "Finish this" not in _task_list(status="todo") assert "Finish this" not in _task_list(status="todo", priority=None)
def test_filter_by_status(self): def test_filter_by_status(self):
from tools.tasks import _task_list from tools.tasks import _task_list
self._mk("A task") self._mk("A task")
assert "A task" in _task_list(status="todo") assert "A task" in _task_list(status="todo", priority=None)
assert "A task" not in _task_list(status="done") assert "A task" not in _task_list(status="done", priority=None)
def test_update_unknown_id(self): def test_update_unknown_id(self):
from tools.tasks import _task_update from tools.tasks import _task_update
@@ -231,7 +231,8 @@ class TestCronTools:
def _extract_id(self, result: str) -> str: def _extract_id(self, result: str) -> str:
import re 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}" assert m, f"No cron ID in: {result}"
return m.group() return m.group()

View File

@@ -2,6 +2,10 @@
Webhook auth tests — NC Talk HMAC, Google Chat JWT. Webhook auth tests — NC Talk HMAC, Google Chat JWT.
These tests verify that auth is enforced, not that full LLM responses work. 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 hashlib
import hmac import hmac
@@ -26,6 +30,14 @@ _VALID_NC_PAYLOAD = {
"target": {"id": "abc123token"}, "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: def _nc_headers(body: bytes, secret: str) -> dict:
random_str = "abc123" random_str = "abc123"
@@ -43,11 +55,11 @@ def _nc_headers(body: bytes, secret: str) -> dict:
@pytest.mark.anyio @pytest.mark.anyio
async def test_nct_valid_signature(client, mock_llm): async def test_nct_valid_signature(client, mock_llm):
body = json.dumps(_VALID_NC_PAYLOAD).encode() 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): with patch("routers.nextcloud_talk._send_reply", new_callable=AsyncMock):
headers = _nc_headers(body, _NC_SECRET) headers = _nc_headers(body, _NC_SECRET)
r = await client.post( r = await client.post(
"/inara-nextcloud-talk-webhook", "/webhook/nextcloud/scott",
content=body, content=body,
headers={**headers, "Content-Type": "application/json"}, headers={**headers, "Content-Type": "application/json"},
) )
@@ -57,9 +69,9 @@ async def test_nct_valid_signature(client, mock_llm):
@pytest.mark.anyio @pytest.mark.anyio
async def test_nct_wrong_signature(client): async def test_nct_wrong_signature(client):
body = json.dumps(_VALID_NC_PAYLOAD).encode() 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( r = await client.post(
"/inara-nextcloud-talk-webhook", "/webhook/nextcloud/scott",
content=body, content=body,
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -73,9 +85,9 @@ async def test_nct_wrong_signature(client):
@pytest.mark.anyio @pytest.mark.anyio
async def test_nct_missing_signature(client): async def test_nct_missing_signature(client):
body = json.dumps(_VALID_NC_PAYLOAD).encode() 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( r = await client.post(
"/inara-nextcloud-talk-webhook", "/webhook/nextcloud/scott",
content=body, content=body,
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
@@ -84,11 +96,13 @@ async def test_nct_missing_signature(client):
@pytest.mark.anyio @pytest.mark.anyio
async def test_nct_no_secret_configured(client): 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() 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( r = await client.post(
"/inara-nextcloud-talk-webhook", "/webhook/nextcloud/scott",
content=body, content=body,
headers={"Content-Type": "application/json"}, 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).""" """Messages from other bots should be silently ignored (not processed)."""
payload = {**_VALID_NC_PAYLOAD, "actor": {"type": "bots", "id": "otherbot", "name": "Bot"}} payload = {**_VALID_NC_PAYLOAD, "actor": {"type": "bots", "id": "otherbot", "name": "Bot"}}
body = json.dumps(payload).encode() 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) headers = _nc_headers(body, _NC_SECRET)
r = await client.post( r = await client.post(
"/inara-nextcloud-talk-webhook", "/webhook/nextcloud/scott",
content=body, content=body,
headers={**headers, "Content-Type": "application/json"}, 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 @pytest.mark.anyio
async def test_gchat_no_audience_configured(client, mock_llm): async def test_gchat_no_audience_configured(client, mock_llm):
"""When audience is not set, JWT check is skipped (current behaviour — documented bypass).""" """When audience is not set, JWT check is skipped (current behaviour — documented bypass)."""
with patch("config.settings.google_chat_audience", ""): with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_NO_AUDIENCE):
r = await client.post("/channels/google-chat", json=_GCHAT_PAYLOAD) r = await client.post("/channels/google-chat/scott", json=_GCHAT_PAYLOAD)
# Should process the message (no auth enforcement when audience is empty)
assert r.status_code == 200 assert r.status_code == 200
@pytest.mark.anyio @pytest.mark.anyio
async def test_gchat_missing_token_with_audience(client): async def test_gchat_missing_token_with_audience(client):
"""When audience IS configured, requests without a token must be rejected.""" """When audience IS configured, requests without a token must be rejected."""
with patch("config.settings.google_chat_audience", "123456789"): with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_WITH_AUDIENCE):
r = await client.post("/channels/google-chat", json=_GCHAT_PAYLOAD) r = await client.post("/channels/google-chat/scott", json=_GCHAT_PAYLOAD)
assert r.status_code == 401 assert r.status_code == 401
@@ -149,8 +171,8 @@ async def test_gchat_invalid_token_with_audience(client):
**_GCHAT_PAYLOAD, **_GCHAT_PAYLOAD,
"authorizationEventObject": {"systemIdToken": "not.a.valid.jwt"}, "authorizationEventObject": {"systemIdToken": "not.a.valid.jwt"},
} }
with patch("config.settings.google_chat_audience", "123456789"): with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_WITH_AUDIENCE):
r = await client.post("/channels/google-chat", json=payload_with_token) r = await client.post("/channels/google-chat/scott", json=payload_with_token)
assert r.status_code == 401 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): async def test_gchat_added_to_space(client, mock_llm):
"""Bot added to a space — should return a greeting, no auth when audience empty.""" """Bot added to a space — should return a greeting, no auth when audience empty."""
payload = {"chat": {"addedToSpacePayload": {"space": {"type": "ROOM"}}}} payload = {"chat": {"addedToSpacePayload": {"space": {"type": "ROOM"}}}}
with patch("config.settings.google_chat_audience", ""): with patch("routers.google_chat.get_user_channels", return_value=_GCHAT_CHANNELS_NO_AUDIENCE):
r = await client.post("/channels/google-chat", json=payload) r = await client.post("/channels/google-chat/scott", json=payload)
assert r.status_code == 200 assert r.status_code == 200
assert "hostAppDataAction" in r.json() assert "hostAppDataAction" in r.json()

View File

@@ -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. 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. 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. 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 import asyncio
@@ -33,12 +37,147 @@ _PROJECT_ALIASES: dict[str, str] = {
_MAX_OUTPUT_CHARS = 12_000 _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( async def aider_run(
project: str, project: str,
task: str, task: str,
files: list[str] | None = None, files: list[str] | None = None,
model: str | None = None, model: str | None = None,
host_label: str | None = None,
auto_commit: bool = True, auto_commit: bool = True,
timeout: int = 300, timeout: int = 300,
background: bool = False, background: bool = False,
@@ -46,6 +185,9 @@ async def aider_run(
) -> str: ) -> str:
"""Run Aider with a single task in a project directory, then exit. """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 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 immediately. Use agent_status(agent_id) to check progress; set notify=True to
receive a push/Talk notification on completion. receive a push/Talk notification on completion.
@@ -58,6 +200,18 @@ async def aider_run(
timeout = min(max(int(timeout), 10), 600) 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] = [ cmd: list[str] = [
"aider", "aider",
"--message", task, "--message", task,
@@ -69,22 +223,7 @@ async def aider_run(
"--auto-commits" if auto_commit else "--no-auto-commits", "--auto-commits" if auto_commit else "--no-auto-commits",
] ]
# Inject OpenRouter credentials from the Cortex model registry if available. cmd += extra_cred_flags
# 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
if model: if model:
cmd += ["--model", model] cmd += ["--model", model]
@@ -93,8 +232,8 @@ async def aider_run(
cmd += ["--file", f] cmd += ["--file", f]
logger.info( logger.info(
"aider_run: project=%s model=%s auto_commit=%s files=%s background=%s task=%.120s", "aider_run: project=%s model=%s host_label=%s auto_commit=%s background=%s task=%.120s",
project, model, auto_commit, files, background, task, project, model, host_label, auto_commit, background, task,
) )
async def _run() -> str: async def _run() -> str:
@@ -181,9 +320,9 @@ DECLARATIONS = [
description=( description=(
"Run the Aider AI coding agent on a project with a single task, then exit. " "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. " "Aider maps the repo, edits files, runs lint checks, and optionally commits. "
"Use for code changes, bug fixes, refactoring, or new features across any " "Credentials are resolved automatically from the Cortex model registry — "
"configured project. Model is set via AIDER_MODEL env var or .aider.conf.yml " "OpenRouter, local Open WebUI/Ollama, Anthropic API, and other configured hosts "
"in the project directory — no API key needed if the project is already configured. " "are all supported. Use host_label to pick a specific host. "
"Set background=True for long tasks — returns an agent_id immediately and sends " "Set background=True for long tasks — returns an agent_id immediately and sends "
"a notification when done. ADMIN ONLY. Requires confirmation." "a notification when done. ADMIN ONLY. Requires confirmation."
), ),
@@ -195,36 +334,45 @@ DECLARATIONS = [
description=( description=(
"Project alias or absolute path. Known aliases: " "Project alias or absolute path. Known aliases: "
"'cortex' (this project), 'aether_api', 'aether_frontend', " "'cortex' (this project), 'aether_api', 'aether_frontend', "
"'aether_container'. Or provide an absolute path like " "'aether_container'. Or provide an absolute path."
"'/home/scott/OSIT_dev/aether_api_fastapi'."
), ),
), ),
"task": types.Schema( "task": types.Schema(
type=types.Type.STRING, type=types.Type.STRING,
description=( description=(
"Full task description sent to Aider as --message. " "Full task description sent to Aider as --message. "
"Be specific — include file names, what to change, and why. " "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.'"
), ),
), ),
"files": types.Schema( "files": types.Schema(
type=types.Type.ARRAY, type=types.Type.ARRAY,
items=types.Schema(type=types.Type.STRING), items=types.Schema(type=types.Type.STRING),
description=( description=(
"Optional list of files to add explicitly to the editing context " "Optional files to add explicitly to the editing context "
"(paths relative to the project root). " "(paths relative to project root). Aider builds a repo map "
"Aider also builds a repo map automatically — these get priority." "automatically — these get priority."
), ),
), ),
"model": types.Schema( "model": types.Schema(
type=types.Type.STRING, type=types.Type.STRING,
description=( description=(
"Optional model override. Examples: 'deepseek/deepseek-chat', " "Optional model override. Format depends on the provider: "
"'openrouter/anthropic/claude-3-5-haiku-20241022'. " "'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." "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( "auto_commit": types.Schema(
type=types.Type.BOOLEAN, type=types.Type.BOOLEAN,
description=( description=(