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>
407 lines
16 KiB
Python
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"],
|
|
),
|
|
)
|
|
]
|