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
|
# 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",
|
||||||
|
|||||||
@@ -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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,8 +334,7 @@ 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(
|
||||||
@@ -204,27 +342,37 @@ DECLARATIONS = [
|
|||||||
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=(
|
||||||
|
|||||||
Reference in New Issue
Block a user