feat: add runtime provider/model switching via /model <tier> <provider/model>
- ModelRouter: add setClient(), labels map, getLabel(), getAllLabels() - TUI commands: parse /model <tier> <provider/model> syntax with autocompletion - TUI minimal: handle provider switching via createClientFromConfig factory - Daemon: wire initial labels into router config - Fix /model alias mappings (opus=complex, sonnet=default, haiku=fast) - Add design doc and update state.json with feature status
This commit is contained in:
@@ -43,6 +43,32 @@ describe('parseCommand', () => {
|
||||
expect(parseCommand('/model opus')).toEqual({ type: 'model', name: 'opus' });
|
||||
});
|
||||
|
||||
it('parses /model with provider/model', () => {
|
||||
expect(parseCommand('/model default anthropic/claude-sonnet-4')).toEqual({
|
||||
type: 'model',
|
||||
name: 'default',
|
||||
providerModel: 'anthropic/claude-sonnet-4',
|
||||
});
|
||||
expect(parseCommand('/model fast github-copilot/gpt-4o-mini')).toEqual({
|
||||
type: 'model',
|
||||
name: 'fast',
|
||||
providerModel: 'github-copilot/gpt-4o-mini',
|
||||
});
|
||||
expect(parseCommand('/model complex openai/o3')).toEqual({
|
||||
type: 'model',
|
||||
name: 'complex',
|
||||
providerModel: 'openai/o3',
|
||||
});
|
||||
});
|
||||
|
||||
it('still parses /model fast as tier switch (no providerModel)', () => {
|
||||
expect(parseCommand('/model fast')).toEqual({ type: 'model', name: 'fast' });
|
||||
});
|
||||
|
||||
it('still parses /model as info (no args)', () => {
|
||||
expect(parseCommand('/model')).toEqual({ type: 'model' });
|
||||
});
|
||||
|
||||
it('parses /backend command without argument', () => {
|
||||
expect(parseCommand('/backend')).toEqual({ type: 'backend' });
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ export type Command =
|
||||
| { type: 'fullscreen' }
|
||||
| { type: 'compact' }
|
||||
| { type: 'usage' }
|
||||
| { type: 'model'; name?: string }
|
||||
| { type: 'model'; name?: string; providerModel?: string }
|
||||
| { type: 'backend'; provider?: string }
|
||||
| { type: 'login'; provider?: string }
|
||||
| { type: 'transfer'; target: string }
|
||||
@@ -56,7 +56,16 @@ export function parseCommand(input: string): Command | null {
|
||||
return { type: 'model' };
|
||||
}
|
||||
if (trimmed.startsWith('/model ')) {
|
||||
const name = trimmed.slice('/model '.length).trim();
|
||||
const args = trimmed.slice('/model '.length).trim();
|
||||
const parts = args.split(/\s+/);
|
||||
|
||||
// /model <tier> <provider/model> - change tier's provider/model
|
||||
if (parts.length === 2 && parts[1].includes('/')) {
|
||||
return { type: 'model', name: parts[0], providerModel: parts[1] };
|
||||
}
|
||||
|
||||
// /model <name> - single word (backward compatibility)
|
||||
const name = parts[0];
|
||||
return { type: 'model', name };
|
||||
}
|
||||
|
||||
@@ -92,7 +101,8 @@ export function getHelpText(): string {
|
||||
return `
|
||||
Commands:
|
||||
/help, /? Show this help
|
||||
/model [name] Show or switch model (local, default, fast, complex)
|
||||
/model [name] Show or switch model tier (local, default, fast, complex)
|
||||
/model <tier> <p/m> Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4)
|
||||
/backend [provider] Show or switch local backend (ollama, llamacpp)
|
||||
/login [provider] Authenticate with GitHub
|
||||
/reset, /clear, /new Clear conversation history
|
||||
@@ -105,7 +115,7 @@ Commands:
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export type ModelAlias = 'local' | 'default' | 'fast' | 'complex' | 'opus' | 'sonnet' | 'ollama';
|
||||
export type ModelAlias = 'local' | 'default' | 'fast' | 'complex' | 'opus' | 'sonnet' | 'haiku' | 'ollama';
|
||||
|
||||
// List of all slash commands for autocompletion
|
||||
export const SLASH_COMMANDS = [
|
||||
@@ -146,28 +156,44 @@ export const COMMAND_TOOLTIPS: Record<string, string> = {
|
||||
};
|
||||
|
||||
// Model aliases for /model command autocompletion
|
||||
export const MODEL_ALIASES = ['local', 'default', 'fast', 'complex', 'opus', 'sonnet', 'ollama'];
|
||||
export const MODEL_ALIASES = ['local', 'default', 'fast', 'complex', 'opus', 'sonnet', 'haiku', 'ollama'];
|
||||
|
||||
// Provider names for /model <tier> <provider/model> syntax
|
||||
export const PROVIDER_NAMES = ['anthropic', 'openai', 'github-copilot', 'gemini', 'bedrock', 'ollama', 'llamacpp'];
|
||||
|
||||
// Model alias descriptions
|
||||
export const MODEL_TOOLTIPS: Record<string, string> = {
|
||||
local: 'Local Ollama model',
|
||||
default: 'Default model (Opus)',
|
||||
fast: 'Fast model (Sonnet)',
|
||||
complex: 'Complex reasoning model',
|
||||
opus: 'Claude Opus',
|
||||
sonnet: 'Claude Sonnet',
|
||||
ollama: 'Local Ollama model',
|
||||
local: 'Local model (Ollama/llama.cpp)',
|
||||
default: 'Default model tier',
|
||||
fast: 'Fast/lightweight model tier',
|
||||
complex: 'Complex reasoning model tier',
|
||||
opus: 'Alias for complex tier',
|
||||
sonnet: 'Alias for default tier',
|
||||
haiku: 'Alias for fast tier',
|
||||
ollama: 'Alias for local tier',
|
||||
};
|
||||
|
||||
export function getCommandCompletions(partial: string): string[] {
|
||||
const trimmed = partial.trim();
|
||||
|
||||
// Complete /model arguments
|
||||
// Complete /model <tier> <provider/model>
|
||||
if (trimmed.startsWith('/model ')) {
|
||||
const modelPartial = trimmed.slice('/model '.length).toLowerCase();
|
||||
return MODEL_ALIASES
|
||||
.filter(alias => alias.startsWith(modelPartial))
|
||||
.map(alias => `/model ${alias}`);
|
||||
const args = trimmed.slice('/model '.length).trim();
|
||||
const parts = args.split(/\s+/);
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Single word - suggest model aliases
|
||||
const modelPartial = parts[0].toLowerCase();
|
||||
return MODEL_ALIASES
|
||||
.filter(alias => alias.startsWith(modelPartial))
|
||||
.map(alias => `/model ${alias}`);
|
||||
} else if (parts.length === 2) {
|
||||
// Two words - suggest provider prefixes
|
||||
const providerPartial = parts[1].toLowerCase();
|
||||
return PROVIDER_NAMES
|
||||
.filter(provider => provider.startsWith(providerPartial))
|
||||
.map(provider => `/model ${parts[0]} ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Complete slash commands
|
||||
@@ -183,16 +209,30 @@ export function getCommandTooltip(partial: string): string | null {
|
||||
|
||||
// Tooltip for /model arguments
|
||||
if (trimmed.startsWith('/model ')) {
|
||||
const modelArg = trimmed.slice('/model '.length).trim();
|
||||
if (modelArg && MODEL_TOOLTIPS[modelArg]) {
|
||||
return MODEL_TOOLTIPS[modelArg];
|
||||
const args = trimmed.slice('/model '.length).trim();
|
||||
const parts = args.split(/\s+/);
|
||||
|
||||
if (parts.length === 1) {
|
||||
// Single word - model tier or provider
|
||||
const modelArg = parts[0].toLowerCase();
|
||||
if (modelArg && MODEL_TOOLTIPS[modelArg]) {
|
||||
return MODEL_TOOLTIPS[modelArg];
|
||||
}
|
||||
// Show tooltip for partial match
|
||||
const matches = MODEL_ALIASES.filter(a => a.startsWith(modelArg));
|
||||
if (matches.length === 1 && MODEL_TOOLTIPS[matches[0]]) {
|
||||
return MODEL_TOOLTIPS[matches[0]];
|
||||
}
|
||||
return 'Choose: local, default, fast, complex';
|
||||
} else if (parts.length === 2) {
|
||||
// Two words - tier + provider
|
||||
const providerPartial = parts[1].toLowerCase();
|
||||
const matches = PROVIDER_NAMES.filter(p => p.startsWith(providerPartial));
|
||||
if (matches.length === 1) {
|
||||
return `Enter provider/model (e.g. ${matches[0]}/...)`;
|
||||
}
|
||||
return `Enter provider/model (e.g. anthropic/claude-sonnet-4)`;
|
||||
}
|
||||
// Show tooltip for partial match
|
||||
const matches = MODEL_ALIASES.filter(a => a.startsWith(modelArg));
|
||||
if (matches.length === 1 && MODEL_TOOLTIPS[matches[0]]) {
|
||||
return MODEL_TOOLTIPS[matches[0]];
|
||||
}
|
||||
return 'Choose: local, default, fast, complex';
|
||||
}
|
||||
|
||||
// Exact match tooltip
|
||||
@@ -216,10 +256,11 @@ export function resolveModelAlias(alias: string): 'local' | 'default' | 'fast' |
|
||||
local: 'local',
|
||||
ollama: 'local',
|
||||
default: 'default',
|
||||
opus: 'default',
|
||||
sonnet: 'default',
|
||||
fast: 'fast',
|
||||
sonnet: 'fast',
|
||||
haiku: 'fast',
|
||||
complex: 'complex',
|
||||
opus: 'complex',
|
||||
};
|
||||
return map[alias.toLowerCase()] ?? 'default';
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, ge
|
||||
import { renderMarkdown } from './markdown.js';
|
||||
import type { ModelConfig } from '../../config/schema.js';
|
||||
import { OllamaClient, LlamaCppClient } from '../../models/index.js';
|
||||
import { createClientFromConfig } from '../../daemon/index.js';
|
||||
import { loginGitHub } from '../../auth/index.js';
|
||||
|
||||
export { parseCommand, type Command };
|
||||
@@ -180,7 +181,7 @@ export class MinimalTui {
|
||||
break;
|
||||
|
||||
case 'model':
|
||||
this.handleModelCommand(command.name);
|
||||
this.handleModelCommand(command.name, command.providerModel);
|
||||
break;
|
||||
|
||||
case 'backend':
|
||||
@@ -201,21 +202,51 @@ export class MinimalTui {
|
||||
}
|
||||
}
|
||||
|
||||
private handleModelCommand(name?: string): void {
|
||||
private handleModelCommand(name?: string, providerModel?: string): void {
|
||||
const router = this.config.modelRouter;
|
||||
if (!router) {
|
||||
console.log(`${colors.gray}Model switching not available.${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
const current = router.getTier();
|
||||
const available = router.getAvailableTiers();
|
||||
console.log(`${colors.gray}Current model:${colors.reset} ${current}`);
|
||||
console.log(`${colors.gray}Available:${colors.reset} ${available.join(', ')}\n`);
|
||||
// /model <tier> <provider/model> — change a tier's provider and model
|
||||
if (name && providerModel) {
|
||||
const tier = resolveModelAlias(name);
|
||||
const slashIdx = providerModel.indexOf('/');
|
||||
if (slashIdx === -1) {
|
||||
console.log(`${colors.gray}Invalid format. Use provider/model (e.g. anthropic/claude-sonnet-4)${colors.reset}\n`);
|
||||
return;
|
||||
}
|
||||
const provider = providerModel.slice(0, slashIdx);
|
||||
const model = providerModel.slice(slashIdx + 1);
|
||||
|
||||
try {
|
||||
const client = createClientFromConfig({ provider: provider as 'anthropic', model });
|
||||
router.setClient(tier, client, providerModel);
|
||||
console.log(`${colors.gray}Set ${tier} to:${colors.reset} ${providerModel}\n`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.log(`${colors.gray}Failed to create client:${colors.reset} ${message}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// /model — show all tiers with labels
|
||||
if (!name) {
|
||||
const current = router.getTier();
|
||||
const available = router.getAvailableTiers();
|
||||
const labels = router.getAllLabels();
|
||||
console.log(`${colors.gray}Active tier:${colors.reset} ${current}`);
|
||||
for (const tier of available) {
|
||||
const label = labels[tier] ?? 'unknown';
|
||||
const marker = tier === current ? ' ←' : '';
|
||||
console.log(` ${tier}: ${label}${marker}`);
|
||||
}
|
||||
console.log();
|
||||
return;
|
||||
}
|
||||
|
||||
// /model <tier> — switch active tier
|
||||
const tier = resolveModelAlias(name);
|
||||
if (router.setTier(tier)) {
|
||||
// Also update the agent tier so chatWithRouter uses the correct client
|
||||
|
||||
Reference in New Issue
Block a user