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:
@@ -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