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

419 lines
10 KiB
Markdown

# 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:
```typescript
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
```bash
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`:
```typescript
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:
```typescript
private localProviderName?: string;
```
2. Add methods after `getClient()` (after line 113):
```typescript
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
```bash
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`:
```typescript
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):
```typescript
| { type: 'backend'; provider?: string }
```
2. Add parsing after the model block (after line 47):
```typescript
// 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 };
}
```
3. Add to getHelpText() (after line 63, before /reset):
```typescript
/backend [provider] Show or switch local backend (ollama, llamacpp)
```
4. Add to SLASH_COMMANDS array (after '/model'):
```typescript
'/backend',
```
5. Add to COMMAND_TOOLTIPS (after '/model'):
```typescript
'/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
```bash
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):
```typescript
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:
```typescript
import type { ModelConfig } from '../../config/schema.js';
import { OllamaClient, LlamaCppClient } from '../../models/index.js';
```
2. Add to MinimalTuiConfig interface (after line 23):
```typescript
localProviders?: Record<string, ModelConfig>;
currentLocalProvider?: string;
```
3. Add case in handleCommand switch (after line 100, before 'message'):
```typescript
case 'backend':
this.handleBackendCommand(command.provider);
break;
```
4. Add handler method (after handleModelCommand, around line 130):
```typescript
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
```bash
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
```bash
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