Files
flynn/docs/plans/2026-02-05-backend-switch-implementation.md
T
2026-02-05 13:32:35 -08:00

10 KiB

Backend Switch Command Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Add /backend slash command to switch between local LLM providers (ollama, llamacpp) at runtime.

Architecture: Extend config schema with local_providers object, add setLocalClient() to ModelRouter, add command parsing and handling in TUI.

Tech Stack: TypeScript, Zod (config validation), Vitest (testing)


Task 1: Extend Config Schema

Files:

  • Modify: src/config/schema.ts

Step 1: Add local_providers to schema

Update src/config/schema.ts - add local_providers field to modelsSchema:

const modelsSchema = z.object({
  local: modelConfigSchema.optional(),
  fast: modelConfigSchema.optional(),
  default: modelConfigSchema,
  complex: modelConfigSchema.optional(),
  fallback_chain: z.array(z.string()).default(['anthropic']),
  local_providers: z.record(z.string(), modelConfigSchema).optional(),
});

Step 2: Verify build

Run: npx tsc --noEmit Expected: No errors

Step 3: Commit

git add src/config/schema.ts
git commit -m "feat: add local_providers to config schema"

Task 2: Add ModelRouter Methods

Files:

  • Modify: src/models/router.ts
  • Modify: src/models/router.test.ts

Step 1: Write the failing test

Add to src/models/router.test.ts:

describe('ModelRouter local client switching', () => {
  it('allows setting a new local client', () => {
    const mockDefault = { chat: vi.fn() } as unknown as ModelClient;
    const mockLocal1 = { chat: vi.fn() } as unknown as ModelClient;
    const mockLocal2 = { chat: vi.fn() } as unknown as ModelClient;

    const router = new ModelRouter({
      default: mockDefault,
      local: mockLocal1,
      fallbackChain: [],
    });

    expect(router.getLocalProviderName()).toBe(undefined);

    router.setLocalClient(mockLocal2, 'llamacpp');

    expect(router.getLocalProviderName()).toBe('llamacpp');
    expect(router.getClient('local')).toBe(mockLocal2);
  });
});

Step 2: Run test to verify it fails

Run: npm test -- src/models/router.test.ts Expected: FAIL with "setLocalClient is not a function" or "getLocalProviderName is not a function"

Step 3: Write minimal implementation

Add to src/models/router.ts:

  1. Add private field after line 17:
private localProviderName?: string;
  1. Add methods after getClient() (after line 113):
setLocalClient(client: ModelClient, providerName: string): void {
  this.clients.set('local', client);
  this.localProviderName = providerName;
}

getLocalProviderName(): string | undefined {
  return this.localProviderName;
}

Step 4: Run test to verify it passes

Run: npm test -- src/models/router.test.ts Expected: PASS

Step 5: Commit

git add src/models/router.ts src/models/router.test.ts
git commit -m "feat: add setLocalClient and getLocalProviderName to ModelRouter"

Task 3: Add Backend Command Parsing

Files:

  • Modify: src/frontends/tui/commands.ts
  • Modify: src/frontends/tui/commands.test.ts

Step 1: Write the failing tests

Add to src/frontends/tui/commands.test.ts:

it('parses /backend command without argument', () => {
  expect(parseCommand('/backend')).toEqual({ type: 'backend' });
});

it('parses /backend command with argument', () => {
  expect(parseCommand('/backend llamacpp')).toEqual({ type: 'backend', provider: 'llamacpp' });
  expect(parseCommand('/backend ollama')).toEqual({ type: 'backend', provider: 'ollama' });
});

Step 2: Run test to verify it fails

Run: npm test -- src/frontends/tui/commands.test.ts Expected: FAIL - returns message type instead of backend type

Step 3: Implement command parsing

Update src/frontends/tui/commands.ts:

  1. Add to Command type (after line 8):
| { type: 'backend'; provider?: string }
  1. Add parsing after the model block (after line 47):
// Backend (with optional argument)
if (trimmed === '/backend') {
  return { type: 'backend' };
}
if (trimmed.startsWith('/backend ')) {
  const provider = trimmed.slice('/backend '.length).trim();
  return { type: 'backend', provider };
}
  1. Add to getHelpText() (after line 63, before /reset):
  /backend [provider]    Show or switch local backend (ollama, llamacpp)
  1. Add to SLASH_COMMANDS array (after '/model'):
'/backend',
  1. Add to COMMAND_TOOLTIPS (after '/model'):
'/backend': 'Show or switch local backend (ollama, llamacpp)',

Step 4: Run test to verify it passes

Run: npm test -- src/frontends/tui/commands.test.ts Expected: PASS

Step 5: Commit

git add src/frontends/tui/commands.ts src/frontends/tui/commands.test.ts
git commit -m "feat: add /backend command parsing"

Task 4: Add Backend Command Handler to MinimalTui

Files:

  • Modify: src/frontends/tui/minimal.ts
  • Modify: src/frontends/tui/minimal.test.ts

Step 1: Write the failing test

Add to src/frontends/tui/minimal.test.ts (import ModelConfig type first):

import type { ModelConfig } from '../../config/schema.js';

describe('MinimalTui backend command', () => {
  it('switches local backend when provider is configured', async () => {
    const mockSession = {
      id: 'test',
      getHistory: () => [],
      addMessage: vi.fn(),
      clear: vi.fn(),
    };

    const mockRouter = {
      getTier: () => 'default' as const,
      getAvailableTiers: () => ['default', 'local'],
      setTier: vi.fn(() => true),
      getLocalProviderName: () => 'ollama',
      setLocalClient: vi.fn(),
      chat: vi.fn(),
      getClient: vi.fn(),
    };

    const localProviders: Record<string, ModelConfig> = {
      llamacpp: {
        provider: 'llamacpp',
        model: '',
        endpoint: 'http://localhost:8080',
      },
    };

    const tui = new MinimalTui({
      session: mockSession as any,
      modelClient: mockRouter as any,
      modelRouter: mockRouter as any,
      systemPrompt: 'test',
      localProviders,
    });

    // Access private method for testing
    await (tui as any).handleBackendCommand('llamacpp');

    expect(mockRouter.setLocalClient).toHaveBeenCalled();
  });
});

Step 2: Run test to verify it fails

Run: npm test -- src/frontends/tui/minimal.test.ts Expected: FAIL - handleBackendCommand doesn't exist or localProviders not accepted

Step 3: Implement handler

Update src/frontends/tui/minimal.ts:

  1. Add import at top:
import type { ModelConfig } from '../../config/schema.js';
import { OllamaClient, LlamaCppClient } from '../../models/index.js';
  1. Add to MinimalTuiConfig interface (after line 23):
localProviders?: Record<string, ModelConfig>;
currentLocalProvider?: string;
  1. Add case in handleCommand switch (after line 100, before 'message'):
case 'backend':
  this.handleBackendCommand(command.provider);
  break;
  1. Add handler method (after handleModelCommand, around line 130):
private handleBackendCommand(provider?: string): void {
  const router = this.config.modelRouter;
  if (!router) {
    console.log('Backend switching not available.\n');
    return;
  }

  if (!provider) {
    const current = router.getLocalProviderName() ?? this.config.currentLocalProvider ?? 'unknown';
    const available = this.getAvailableBackends();
    console.log(`Current local backend: ${current}`);
    console.log(`Available: ${available.join(', ')}\n`);
    return;
  }

  const providerConfig = this.config.localProviders?.[provider];
  if (!providerConfig) {
    const available = this.getAvailableBackends();
    console.log(`Backend '${provider}' not configured.`);
    console.log(`Available: ${available.join(', ')}\n`);
    return;
  }

  const client = this.createLocalClient(providerConfig);
  if (!client) {
    console.log(`Failed to create client for '${provider}'.\n`);
    return;
  }

  router.setLocalClient(client, provider);
  console.log(`Switched to backend: ${provider}\n`);
}

private getAvailableBackends(): string[] {
  const backends: string[] = [];
  if (this.config.currentLocalProvider) {
    backends.push(this.config.currentLocalProvider);
  }
  if (this.config.localProviders) {
    backends.push(...Object.keys(this.config.localProviders));
  }
  return [...new Set(backends)];
}

private createLocalClient(config: ModelConfig): ModelClient | null {
  if (config.provider === 'ollama') {
    return new OllamaClient({
      model: config.model,
      host: config.endpoint,
    });
  }
  if (config.provider === 'llamacpp') {
    return new LlamaCppClient({
      endpoint: config.endpoint ?? 'http://localhost:8080',
      authToken: config.auth_token,
    });
  }
  return null;
}

Step 4: Run test to verify it passes

Run: npm test -- src/frontends/tui/minimal.test.ts Expected: PASS

Step 5: Commit

git add src/frontends/tui/minimal.ts src/frontends/tui/minimal.test.ts
git commit -m "feat: add /backend command handler to MinimalTui"

Task 5: Wire Up Config in TUI Entry Point

Files:

  • Modify: src/tui.ts

Step 1: Read current file and update

Read src/tui.ts to understand current structure, then update to pass localProviders and currentLocalProvider to MinimalTui.

The changes needed:

  1. Extract local_providers from config
  2. Extract current local provider name from config.models.local.provider
  3. Pass both to MinimalTui config

Step 2: Run full test suite

Run: npm test Expected: All tests pass

Step 3: Commit

git add src/tui.ts
git commit -m "feat: wire up localProviders config to TUI"

Task 6: Verify and Final Test

Files: None (verification only)

Step 1: Run full test suite

Run: npm test Expected: All tests pass

Step 2: Type check

Run: npx tsc --noEmit Expected: No errors

Step 3: Build

Run: npm run build Expected: Build succeeds


Summary

Task Description Tests Added
1 Config schema extension 0
2 ModelRouter methods 1
3 Command parsing 2
4 MinimalTui handler 1
5 Wire up config 0
6 Verification 0

Total new tests: 4 Files modified: 7