# BooCoder Provider Picker — Backend (Steps 1–3) > **Superseded:** Shipped as `v2.1.0-provider-picker` (2026-05-25). Agent discovery uses direct `exec()` on the host, not SSH. See `CHANGELOG.md` and `apps/coder/src/services/agent-probe.ts` for current implementation. > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Expose a `GET /api/providers` endpoint on BooCoder (port 9502) that returns all available providers with their model lists, so the frontend can build a two-level provider → model picker. **Architecture:** A static provider registry maps agent names to their metadata (transport, model source). The existing `agent-probe.ts` is extended to discover models for each agent and persist them in a new `models` JSONB column on `available_agents`. A new `/api/providers` route merges the registry with DB state and llama-swap models to produce the response. **Tech Stack:** Fastify, postgres (porsager), Zod, direct host exec for agent discovery (historical plan referenced SSH — superseded at v2.1.0). --- ## File Map | Action | File | Responsibility | |--------|------|----------------| | Create | `apps/coder/src/services/provider-registry.ts` | Static provider metadata (label, transport, model source) | | Modify | `apps/coder/src/schema.sql` | Add `models`, `label`, `transport` columns to `available_agents` | | Modify | `apps/coder/src/services/agent-probe.ts` | Discover models per agent, persist to DB | | Create | `apps/coder/src/routes/providers.ts` | `GET /api/providers` route | | Modify | `apps/coder/src/index.ts` | Register providers route | --- ### Task 1: Provider Registry **Files:** - Create: `apps/coder/src/services/provider-registry.ts` - [ ] **Step 1: Create the provider registry** ```typescript // apps/coder/src/services/provider-registry.ts export interface ProviderDef { name: string; label: string; transport: 'native' | 'acp' | 'pty'; modelSource: 'llama-swap' | 'static'; staticModels?: Array<{ id: string; label: string }>; } export const PROVIDERS: ProviderDef[] = [ { name: 'boocode', label: 'BooCoder', transport: 'native', modelSource: 'llama-swap', }, { name: 'opencode', label: 'OpenCode', transport: 'acp', modelSource: 'llama-swap', }, { name: 'goose', label: 'Goose', transport: 'acp', modelSource: 'llama-swap', }, { name: 'claude', label: 'Claude Code', transport: 'pty', modelSource: 'static', staticModels: [ { id: 'claude-opus-4-20250514', label: 'Opus 4' }, { id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' }, ], }, { name: 'qwen', label: 'Qwen Code', transport: 'pty', modelSource: 'static', // Models discovered at probe time from ~/.qwen/settings.json on host }, ]; export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p])); ``` - [ ] **Step 2: Verify TypeScript compiles** Run: `npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20` Expected: No errors from provider-registry.ts --- ### Task 2: Schema Migration **Files:** - Modify: `apps/coder/src/schema.sql` - [ ] **Step 1: Back up the schema file** Run: `cp apps/coder/src/schema.sql apps/coder/src/schema.sql.bak-$(date +%Y%m%d)` - [ ] **Step 2: Add columns to available_agents** Append to the end of `apps/coder/src/schema.sql`: ```sql -- v2.1.0: provider picker — extend available_agents with model discovery. ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT; ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty'; ``` - [ ] **Step 3: Verify schema applies cleanly** This can't be tested locally without a DB connection. The schema is idempotent (`ADD COLUMN IF NOT EXISTS`), so it's safe to apply on startup. Verify syntax by reading the file. --- ### Task 3: Extend agent-probe for model discovery **Files:** - Modify: `apps/coder/src/services/agent-probe.ts` - [ ] **Step 1: Import the provider registry** Add at the top of `agent-probe.ts`: ```typescript import { PROVIDERS_BY_NAME } from './provider-registry.js'; ``` - [ ] **Step 2: Replace KNOWN_AGENTS with registry-driven list** Replace the `KNOWN_AGENTS` array and its type with a derivation from the provider registry: ```typescript const KNOWN_AGENTS = ['opencode', 'goose', 'claude', 'qwen'].map((name) => ({ name, supportsAcp: PROVIDERS_BY_NAME.get(name)?.transport === 'acp', })); ``` This preserves the same shape the rest of `probeAgents` expects while deriving `supportsAcp` from the registry's `transport` field. `pi` is dropped (no provider def, not actively used). `boocode` is excluded (native — no binary to probe on host). - [ ] **Step 3: Add model discovery after the existing version check** Inside the `for (const agent of KNOWN_AGENTS)` loop, after the ACP check block and before the UPSERT, add model discovery: ```typescript // Discover models for this agent let models: Array<{ id: string; label: string }> = []; const providerDef = PROVIDERS_BY_NAME.get(agent.name); if (providerDef?.modelSource === 'static' && providerDef.staticModels) { models = providerDef.staticModels; } if (agent.name === 'qwen') { try { const catResult = await sshExec('cat ~/.qwen/settings.json', { timeoutMs: 10_000 }); if (catResult.exitCode === 0 && catResult.stdout.trim()) { const settings = JSON.parse(catResult.stdout) as { modelProviders?: { openai?: Array<{ id: string }> }; }; const openaiModels = settings?.modelProviders?.openai; if (Array.isArray(openaiModels)) { models = openaiModels.map((m) => ({ id: m.id, label: m.id })); } } } catch { // ~/.qwen/settings.json missing or unparseable — fall back to empty } } ``` - [ ] **Step 4: Update the UPSERT to include new columns** Replace the existing UPSERT statement with: ```typescript const label = providerDef?.label ?? agent.name; const transport = providerDef?.transport ?? 'pty'; await sql` INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport) VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${JSON.stringify(models)}::jsonb, ${label}, ${transport}) ON CONFLICT (name) DO UPDATE SET install_path = EXCLUDED.install_path, version = EXCLUDED.version, supports_acp = EXCLUDED.supports_acp, last_probed_at = EXCLUDED.last_probed_at, models = EXCLUDED.models, label = EXCLUDED.label, transport = EXCLUDED.transport `; ``` - [ ] **Step 5: Update the log line to include model count** Replace the existing log.info with: ```typescript log.info({ agent: agent.name, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found on host'); ``` - [ ] **Step 6: Verify TypeScript compiles** Run: `npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20` Expected: No errors --- ### Task 4: Goose PTY dispatch **Files:** - Modify: `apps/coder/src/services/pty-dispatch.ts` - [ ] **Step 1: Add goose case to buildAgentCommand** In `pty-dispatch.ts`, replace the goose case (line ~61): ```typescript case 'goose': return model ? `goose run --text '${escapedTask}' --model '${model}'` : `goose run --text '${escapedTask}'`; ``` Note: `goose run --text` is the non-interactive execution flag. If goose's actual CLI differs, the dispatch will fail with a nonzero exit code and the task will be marked `failed` — no silent corruption. - [ ] **Step 2: Update the module docstring** Replace `goose: stub (not yet supported)` with `goose: \`goose run --text \` (non-interactive)` in the header comment. - [ ] **Step 3: Verify TypeScript compiles** Run: `npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20` Expected: No errors --- ### Task 5: GET /api/providers Route **Files:** - Create: `apps/coder/src/routes/providers.ts` - Modify: `apps/coder/src/index.ts` - [ ] **Step 1: Create the providers route** ```typescript // apps/coder/src/routes/providers.ts import type { FastifyInstance } from 'fastify'; import type { Sql } from '../db.js'; import type { Config } from '../config.js'; import { PROVIDERS } from '../services/provider-registry.js'; interface ProviderModel { id: string; label: string; } interface ProviderResponse { name: string; label: string; transport: string; installed: boolean; models: ProviderModel[]; } interface LlamaSwapModel { id: string; [key: string]: unknown; } async function fetchLlamaSwapModels(config: Config): Promise { try { const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`); if (!res.ok) return []; const parsed = (await res.json()) as { data?: LlamaSwapModel[] }; return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id })); } catch { return []; } } export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void { app.get('/api/providers', async (_req, _reply) => { // Fetch llama-swap models (shared by boocode, opencode, goose) const llamaModels = await fetchLlamaSwapModels(config); // Fetch installed agents from DB const agents = await sql<{ name: string; models: ProviderModel[]; label: string | null; transport: string | null }[]>` SELECT name, models, label, transport FROM available_agents `; const agentMap = new Map(agents.map((a) => [a.name, a])); const result: ProviderResponse[] = []; for (const provider of PROVIDERS) { const isNative = provider.name === 'boocode'; const agentRow = agentMap.get(provider.name); const installed = isNative || !!agentRow; if (!installed) continue; let models: ProviderModel[]; if (provider.modelSource === 'llama-swap') { models = llamaModels; } else if (agentRow?.models && agentRow.models.length > 0) { models = agentRow.models; } else if (provider.staticModels) { models = provider.staticModels; } else { models = []; } result.push({ name: provider.name, label: agentRow?.label ?? provider.label, transport: agentRow?.transport ?? provider.transport, installed, models, }); } return result; }); } ``` - [ ] **Step 2: Register the route in index.ts** In `apps/coder/src/index.ts`, add the import near the other route imports (around line 28): ```typescript import { registerProviderRoutes } from './routes/providers.js'; ``` Add the registration call after the other `register*Routes` calls (around line 148): ```typescript registerProviderRoutes(app, sql, config); ``` - [ ] **Step 3: Verify TypeScript compiles** Run: `npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20` Expected: No errors - [ ] **Step 4: Build and test** Run: ```bash docker compose build --no-cache boocode && docker compose up -d ``` Then verify: ```bash curl http://100.114.205.53:9502/api/providers | jq . ``` Expected shape: ```json [ { "name": "boocode", "label": "BooCoder", "transport": "native", "installed": true, "models": [...] }, { "name": "opencode", ... }, ... ] ``` --- ## Checkpoint Verification After Task 5, report: 1. `curl http://100.114.205.53:9502/api/providers` output 2. `available_agents` schema after migration: `psql -h localhost -p 5500 -U boocode -d boochat -c '\d available_agents'` 3. Any issues with qwen model discovery from `~/.qwen/settings.json` **Do NOT proceed to frontend (Step 4 in the spec) without confirming the API works.**