Submatter/docs/adr-model-routing.md

363 lines
15 KiB
Markdown
Raw Normal View History

# ADR: Model routing and provider registry
**Status:** Accepted (draft implementation)
**Date:** 2026-07-03
**Context:** Echodo automation foundation — orchestrator, worker, and mission-control UI without Cursor as the runtime substrate.
## Decision
Echodo will own a **workspace-scoped model provider registry** and an **extensible role → model routing table**. Every automated pipeline run resolves a concrete provider configuration before inference. Resolution is deterministic, logged on every run, and configurable from Settings without redeploying orchestrator or worker code.
## Problem
The agent-pipeline defines per-role models via:
- `model:` frontmatter in `.cursor/agents/role-*.md` (Cursor-only enforcement)
- `model_policy.roles` in convoy frontmatter (recommendation only)
- `recommended_model` on implementer briefs (optional override)
That works for manual Cursor sessions. It does **not** work when:
- An orchestrator dispatches `role-implementer` unattended
- Planning uses Claude API and implementation uses local Ollama on a Mac
- Operators add providers (OpenAI, Gemini, NVIDIA NIM, LiteLLM gateway) without code changes
- Custom roles (e.g. `gate-copilot`, `role-security-auditor`) need routing
We need one contract that web chat, orchestrator, worker, and MCP logging all share.
## Goals
1. **Per-role model selection** — conductor/architect on cloud; implementer/auditors on local coder models.
2. **Extensible providers** — Anthropic, OpenAI, Google (Gemini), OpenAI-compatible (Ollama, LiteLLM, NVIDIA NIM, Azure OpenAI).
3. **Extensible roles** — seed pipeline’s nine L2 roles; allow arbitrary `roleId` rows.
4. **Layered overrides** — workspace defaults → convoy `model_policy` → brief `recommended_model`.
5. **No secrets in DB** — store `apiKeyEnvVar` names; values live in deploy env (Coolify/Dokploy/Mac).
6. **Observable** — every `agent_runs` / `convoy_events` row records alias, provider type, model id, tier.
## Non-goals (this ADR)
- Implementing the orchestrator or worker (separate epics).
- LiteLLM as a required dependency (optional alias pointing at a gateway URL).
- Storing API keys in Postgres or committed YAML.
- Replacing `docs/model-routing-policy.md` tier guidance — this ADR **implements** it in Echodo.
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Settings → AI → Providers (ModelProviderAlias CRUD) │
│ Settings → AI → Role routing (RoleModelRoute CRUD) │
└────────────────────────────┬────────────────────────────────┘
│
resolveModel(workspaceId, roleId, ctx?)
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
apps/web apps/orchestrator apps/worker
(chat, gate (dispatch job (inference
co-pilot) with ModelRef) only)
```
### Resolution order (first match wins)
1. **Brief override** — `recommended_model` alias on `.convoys/<slug>/brief-N-*.md`
2. **Convoy override** — `model_policy.roles[roleId]` in `.convoys/<slug>.md`
3. **Workspace route** — `role_model_routes` row for `(workspace_id, role_id)`
4. **Pipeline template** — shipped defaults (nine L2 roles + `gate-copilot`, `chat-default`)
If the alias is missing or disabled, fail closed with a structured error — do not silently fall back to a hardcoded model.
### Escalation
When `model_policy.escalate_to` is set (convoy) or workspace policy defines escalation:
- After N failed implementer attempts (default: 2), orchestrator re-dispatches the same brief with the escalation alias.
- Log `model_tier` change on the new run.
## Data model
### `model_providers` (per workspace)
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `workspace_id` | uuid FK | Required; all queries filter by this |
| `alias` | varchar | Unique per workspace, e.g. `claude-planning` |
| `display_name` | varchar | UI label |
| `provider_type` | enum | See below |
| `model_id` | varchar | Provider-native model string |
| `base_url` | text nullable | Required for `openai_compatible` |
| `api_key_env_var` | varchar nullable | e.g. `ANTHROPIC_API_KEY` |
| `extra_headers` | jsonb nullable | Optional HTTP headers |
| `default_params` | jsonb nullable | `temperature`, `maxTokens`, … |
| `metadata` | jsonb nullable | `tier`, cost hints for dashboard |
| `enabled` | boolean | Default true |
| `created_at` / `updated_at` | timestamptz | |
**`provider_type` enum:**
| Value | Client | Examples |
|-------|--------|----------|
| `anthropic` | `@ai-sdk/anthropic` | Claude API |
| `openai` | `@ai-sdk/openai` | OpenAI API |
| `google` | `@ai-sdk/google` | Gemini |
| `openai_compatible` | `@ai-sdk/openai` + `baseURL` | Ollama, LiteLLM, **NVIDIA NIM**, vLLM |
| `azure_openai` | `@ai-sdk/openai` + Azure config | Azure deployments |
Future: `custom` with a registered adapter — not in v1.
### `role_model_routes` (per workspace)
| Column | Type | Notes |
|--------|------|-------|
| `id` | uuid PK | |
| `workspace_id` | uuid FK | |
| `role_id` | varchar | e.g. `role-implementer`, `gate-copilot` |
| `provider_alias_id` | uuid FK → `model_providers.id` | |
| `enabled` | boolean | |
| `sort_order` | int | UI ordering |
| `created_at` / `updated_at` | timestamptz | |
Unique constraint: `(workspace_id, role_id)`.
`role_id` is a **string**, not a DB enum — new pipeline roles do not require migrations.
### Resolved config (runtime, not persisted)
```typescript
type ResolvedModel = {
alias: string;
providerType: ProviderType;
modelId: string;
baseUrl?: string;
apiKey: string; // read from process.env[apiKeyEnvVar] at call time
extraHeaders?: Record<string, string>;
defaultParams?: { temperature?: number; maxTokens?: number };
tier?: "premium" | "fast" | "local" | "auto";
};
```
Pure function: `resolveModel(input, db)` lives in `packages/shared` (types + zod) with implementation in `packages/ai` or `packages/shared` (no React, no DB imports in shared — DB fetch in tRPC/orchestrator, pass rows into resolver).
## Provider examples
### Local Ollama (Mac)
```yaml
alias: qwen-coder
provider_type: openai_compatible
model_id: qwen2.5-coder:32b
base_url: http://192.168.1.42:11434/v1
api_key_env_var: OLLAMA_API_KEY # any non-empty string Ollama accepts
metadata: { tier: local }
```
### Local MLX on Apple Silicon (Mac)
[MLX](https://github.com/ml-explore/mlx) is Apple's on-device ML framework. Echodo does **not** talk to MLX directly — use an **OpenAI-compatible HTTP server** in front of MLX, same `provider_type: openai_compatible` as Ollama.
| Server | Package | Default port | When to use |
|--------|---------|--------------|-------------|
| **mlx_lm.server** | `pip install mlx-lm` | 8080 | Solo dev, single implementer, simplest path |
| **mlx-openai-server** | [cubist38/mlx-openai-server](https://github.com/cubist38/mlx-openai-server) | 8000 | **Parallel implementer fan-out** — continuous batching handles concurrent requests better than the reference server |
| mlx-lm-server (community) | [0xDaizz/mlx-lm-server](https://github.com/0xDaizz/mlx-lm-server) | 8000 | Alternative if mlx-openai-server doesn't fit |
**Parallel implementers note:** When the orchestrator dispatches multiple `role-implementer` jobs at once (disjoint briefs per agent-pipeline `slice_dependencies`), a single local inference process can become the bottleneck. Prefer **mlx-openai-server** (or mlx-lm-server with batching) over the minimal `mlx_lm.server` when running 2+ implementers against the same Mac. Ollama can also serve parallel requests but MLX-native models (`mlx-community/*` on Hugging Face) often perform better on Apple Silicon unified memory. You may run **both** Ollama (`:11434`) and an MLX server (`:8000` / `:8080`) as separate registry aliases and route roles accordingly.
```yaml
alias: qwen-coder-mlx
provider_type: openai_compatible
model_id: mlx-community/Qwen2.5-Coder-32B-Instruct-4bit
base_url: http://192.168.1.42:8000/v1 # mlx-openai-server; use :8080/v1 for mlx_lm.server
api_key_env_var: MLX_API_KEY # placeholder; local servers ignore it
metadata: { tier: local, runtime: mlx }
```
**Operator setup (mlx-openai-server example):**
```bash
# Machine-wide install (all repos): ~/mlx-inference/mlx-serve.sh
# See ~/mlx-inference/README.md
~/mlx-inference/mlx-serve.sh
# Or manual:
pip install mlx-lm mlx-openai-server
mlx-openai-server launch \
--model-path mlx-community/Qwen2.5-Coder-32B-Instruct-4bit \
--model-type lm \
--host 0.0.0.0 \
--port 8000
```
**Networking:** Bind `0.0.0.0` when Echodo (Coolify) or `apps/worker` on another host must reach the Mac over LAN. Restrict firewall to homelab subnet. Do not expose raw local inference to the public internet.
**v1 registry:** No `provider_type: mlx` — HTTP gateway only. Native MLX SDK in `apps/worker` is out of scope.
```yaml
alias: claude-planning
provider_type: anthropic
model_id: claude-sonnet-4-20250514
api_key_env_var: ANTHROPIC_API_KEY
metadata: { tier: premium }
```
### NVIDIA NIM
```yaml
alias: nim-llama-70b
provider_type: openai_compatible
model_id: meta/llama-3.1-70b-instruct
base_url: https://integrate.api.nvidia.com/v1
api_key_env_var: NVIDIA_API_KEY
```
### Gemini
```yaml
alias: gemini-flash
provider_type: google
model_id: gemini-2.0-flash
api_key_env_var: GOOGLE_GENERATIVE_AI_API_KEY
```
### LiteLLM gateway (optional)
```yaml
alias: litellm-gateway
provider_type: openai_compatible
model_id: qwen2.5-coder:32b # or per-role model_id in route table
base_url: http://host:4000/v1
api_key_env_var: LITELLM_MASTER_KEY
```
## Default role routing (seed template)
Shipped when a workspace enables pipeline integration:
| role_id | Suggested alias | tier |
|---------|-----------------|------|
| `role-conductor` | `claude-planning` | premium |
| `role-architect` | `claude-planning` | premium |
| `role-ia-architect` | `claude-planning` | premium |
| `role-ux-reviewer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `role-implementer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `role-reviewer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `role-design-system-auditor` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `role-a11y-auditor` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `role-doc-writer` | `qwen-coder` or `qwen-coder-mlx` | fast/local |
| `gate-copilot` | `claude-planning` | premium |
| `chat-default` | workspace choice | — |
Operators remap aliases in Settings; seed is not immutable.
## Convoy / brief integration
Convoy frontmatter uses **registry aliases**, not Cursor model slugs:
```yaml
model_policy:
roles:
role-conductor: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
escalate_to: claude-planning
```
Brief frontmatter:
```yaml
recommended_model: qwen-coder
model_tier: fast
```
Importer/parser for convoy files is out of scope for the first epic; orchestrator reads frontmatter when dispatching.
## API surface (tRPC)
Router: `ai.models` (or `pipeline.models`)
| Procedure | Purpose |
|-----------|---------|
| `listProviders` | Paginated; workspace-scoped |
| `createProvider` / `updateProvider` / `deleteProvider` | Admin-only |
| `testProvider` | One-token completion; returns latency + error detail |
| `listRoleRoutes` | All routes for workspace |
| `upsertRoleRoute` | Set role → alias; supports custom `roleId` |
| `deleteRoleRoute` | Remove custom role |
| `resolveForRole` | Debug: given `roleId` + optional convoy/brief context |
## UI
- `/{workspaceSlug}/settings/ai/providers` — provider table + add/edit dialog + test button
- `/{workspaceSlug}/settings/ai/role-routing` — role → alias table + add custom role
- Link from convoy/gate screens: “Model for this step: `qwen-coder` (change)”
## Logging contract
Extend `agent_runs` (and future `convoy_events`) with:
- `model_alias` varchar nullable
- `provider_type` varchar nullable
- `model_id` varchar nullable
- `model_tier` varchar nullable
`complete_task` MCP tool accepts optional `modelAlias` / `modelTier` for close-out logging.
## Security
- Provider CRUD: workspace admin only (same bar as `updateWorkflowPrompt`).
- `testProvider` runs server-side; rate-limit per workspace.
- Never return resolved API keys to the browser — only alias and env var **name**.
- Worker/orchestrator processes must have env vars for aliases they use.
## Relationship to other ADRs / plans
| Artifact | Relationship |
|----------|----------------|
| `docs/model-routing-policy.md` | Tier guidance; Cursor-era slugs migrate to aliases |
| `plans/Plan-agent-coordination` | `claim_task` / `complete_task` log model fields |
| `plans/Plan-automation-foundation` | Implements this ADR (model registry epic) |
| Future orchestrator epic | Calls `resolveModel` on every dispatch |
| Future worker epic | Consumes `ResolvedModel` only |
## Migration from Cursor slugs
Legacy convoys may reference `claude-4.6-opus-high-thinking` or `composer-2.5-fast`. Optional alias map in workspace metadata:
```json
{
"legacy_model_aliases": {
"claude-4.6-opus-high-thinking": "claude-planning",
"composer-2.5-fast": "qwen-coder"
}
}
```
## Consequences
**Positive**
- Single place to change Claude vs Qwen vs NIM without code deploys.
- Orchestrator and worker stay provider-agnostic.
- Extensible roles support pipeline evolution.
**Negative**
- Operators must configure providers before automation runs (mitigated: test button + clear errors).
- Two sources of truth for convoy overrides (markdown + DB) until convoy lifecycle MCP lands — resolution order is explicit.
## Open questions
1. **Repo-level `echodo.models.yml`** — mirror workspace config in git for bootstrap? Deferred; DB is v1 source.
2. **Per-user overrides** — deferred; workspace-level only for v1.
3. **Budget caps per alias** — defer to LiteLLM or Phase 4 workers.
## References
- Agent-pipeline: `docs/model-routing-policy.md`, `docs/role-reference.md`
- Agent-pipeline v0.4 plan: convoy lifecycle + Echodo integration
- Vercel AI SDK provider packages in `packages/ai`
- MLX: [ml-explore/mlx](https://github.com/ml-explore/mlx), [mlx-lm SERVER.md](https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/SERVER.md), [mlx-openai-server](https://github.com/cubist38/mlx-openai-server)
- Free-tier discovery: [cheahjs/free-llm-api-resources](https://github.com/cheahjs/free-llm-api-resources), [mnfst/awesome-free-llm-apis](https://github.com/mnfst/awesome-free-llm-apis)