Files
Cortex-Inara/cortex/tools/aider.py
Scott Idem 0c1cf3989a 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>
2026-06-03 23:00:45 -04:00

407 lines
16 KiB
Python

"""
Aider coding agent tool — invokes Aider AI pair programming as a subprocess.
Aider handles repo-map generation, file editing, git commits, and linting automatically.
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.
"""
import asyncio
import logging
import os
from pathlib import Path
from google.genai import types
import agent_manager
logger = logging.getLogger(__name__)
_CORTEX_DIR = Path(__file__).parent # .../Cortex_and_Inara_dev/cortex/
_PROJECT_ROOT = _CORTEX_DIR.parent # .../Cortex_and_Inara_dev/
# Known project aliases — expand before passing to subprocess
_PROJECT_ALIASES: dict[str, str] = {
"cortex": str(_PROJECT_ROOT),
"aether_api": "~/OSIT_dev/aether_api_fastapi",
"aether_frontend": "~/OSIT_dev/aether_app_sveltekit",
"aether_container": "~/OSIT_dev/aether_container_env",
}
_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,
notify: bool = False,
) -> 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.
"""
resolved = _PROJECT_ALIASES.get(project, project)
cwd = Path(os.path.expanduser(resolved))
if not cwd.is_dir():
return f"Error: project directory '{resolved}' does not exist."
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,
"--yes-always",
"--no-pretty",
"--no-stream",
"--no-check-update",
"--no-detect-urls",
"--auto-commits" if auto_commit else "--no-auto-commits",
]
cmd += extra_cred_flags
if model:
cmd += ["--model", model]
for f in (files or []):
cmd += ["--file", f]
logger.info(
"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:
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(cwd),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=float(timeout))
out = stdout.decode(errors="replace").strip()
err = stderr.decode(errors="replace").strip()
parts = []
if out:
parts.append(out)
if err:
parts.append(f"[stderr]\n{err}")
combined = "\n".join(parts) if parts else "(no output)"
if len(combined) > _MAX_OUTPUT_CHARS:
half = _MAX_OUTPUT_CHARS // 2
combined = (
combined[:half]
+ f"\n\n[... {len(combined) - _MAX_OUTPUT_CHARS} chars trimmed ...]\n\n"
+ combined[-half:]
)
if proc.returncode not in (0, 1):
return f"[exit {proc.returncode}]\n{combined}"
return combined
if background:
rec = await agent_manager.register(
user=user,
role="aider",
task=task,
level=2,
notify=notify,
)
async def _bg_task() -> None:
try:
result = await _run()
await agent_manager.finish(rec.agent_id, result, "done")
logger.info("aider_run [bg]: done %s", rec.agent_id[:8])
except asyncio.CancelledError:
await agent_manager.finish(rec.agent_id, "Cancelled.", "cancelled")
raise
except asyncio.TimeoutError:
msg = f"Aider timed out after {timeout}s"
logger.warning("aider_run [bg]: timeout %s", rec.agent_id[:8])
await agent_manager.finish(rec.agent_id, msg, "timeout")
except FileNotFoundError:
msg = "Error: 'aider' not found in PATH — run: pip install aider-chat"
await agent_manager.finish(rec.agent_id, msg, "failed")
except Exception as e:
logger.error("aider_run [bg]: failed %s: %s", rec.agent_id[:8], e)
await agent_manager.finish(rec.agent_id, str(e), "failed")
bg = asyncio.create_task(_bg_task())
agent_manager.set_task_ref(rec.agent_id, bg)
return (
f"Aider task started in background. ID: {rec.agent_id}\n"
f"Use agent_status('{rec.agent_id}') to monitor progress."
)
# Synchronous path
try:
return await _run()
except asyncio.TimeoutError:
return f"Error: aider timed out after {timeout}s"
except FileNotFoundError:
return "Error: 'aider' not found in PATH — run: pip install aider-chat"
except Exception as e:
logger.error("aider_run error: %s", e)
return f"Error: {e}"
DECLARATIONS = [
types.FunctionDeclaration(
name="aider_run",
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. "
"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."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"project": types.Schema(
type=types.Type.STRING,
description=(
"Project alias or absolute path. Known aliases: "
"'cortex' (this project), 'aether_api', 'aether_frontend', "
"'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."
),
),
"files": types.Schema(
type=types.Type.ARRAY,
items=types.Schema(type=types.Type.STRING),
description=(
"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. 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=(
"Auto-commit changes after edits (default: true). "
"Set to false to review diffs before committing manually."
),
),
"timeout": types.Schema(
type=types.Type.INTEGER,
description="Max seconds to wait for Aider to finish (default 300, max 600).",
),
"background": types.Schema(
type=types.Type.BOOLEAN,
description=(
"Run asynchronously in the background (default: false). "
"Returns an agent_id immediately; use agent_status(agent_id) to monitor. "
"Recommended for tasks expected to take more than ~60 seconds."
),
),
"notify": types.Schema(
type=types.Type.BOOLEAN,
description=(
"Send a push/Talk notification when the background task completes "
"(default: false). Only applies when background=true."
),
),
},
required=["project", "task"],
),
)
]