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

12 KiB
Raw Blame History

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

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

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

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:

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:

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

      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:

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

    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

// 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):

import { registerProviderRoutes } from './routes/providers.js';

Add the registration call after the other register*Routes calls (around line 148):

  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:

docker compose build --no-cache boocode && docker compose up -d

Then verify:

curl http://100.114.205.53:9502/api/providers | jq .

Expected shape:

[
  { "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.