Files
boocode/docs/superpowers/plans/2026-05-25-provider-picker-backend.md
indifferentketchup 93d3f86c2b v2.2-paseo-providers: Paseo provider stack + v2.2.1 pane-scoped chat fixes
Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch
rewrite with streaming/persist, permission prompts, and agent commands.
Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline,
WS user-delta replace, and inference orphan tool_call stripping.
Archive openspec v2-2; update CHANGELOG and CURRENT.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-26 15:18:31 +00:00

382 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# BooCoder Provider Picker — Backend (Steps 13)
> **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 <task>\` (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<ProviderModel[]> {
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.**