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:
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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=(
|
||||
|
||||
Reference in New Issue
Block a user