mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
feat: refactor Claude API Profiles to Claude Compatible Providers
- Rename ClaudeApiProfile to ClaudeCompatibleProvider with models[] array - Each ProviderModel has mapsToClaudeModel field for Claude tier mapping - Add providerType field for provider-specific icons (glm, minimax, openrouter) - Add thinking level support for provider models in phase selectors - Show all mapped Claude models per provider model (e.g., "Maps to Haiku, Sonnet, Opus") - Add Bulk Replace feature to switch all phases to a provider at once - Hide Bulk Replace button when no providers are enabled - Fix project-level phaseModelOverrides not persisting after refresh - Fix deleting last provider not persisting (remove empty array guard) - Add getProviderByModelId() helper for all SDK routes - Update all routes to pass provider config for provider models - Update terminology from "profiles" to "providers" throughout UI - Update documentation to reflect new provider system
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ArrowRight, Cloud, Server, Check, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
PhaseModelKey,
|
||||
PhaseModelEntry,
|
||||
ClaudeCompatibleProvider,
|
||||
ClaudeModelAlias,
|
||||
} from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
|
||||
interface BulkReplaceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// Phase display names for preview
|
||||
const PHASE_LABELS: Record<PhaseModelKey, string> = {
|
||||
enhancementModel: 'Feature Enhancement',
|
||||
fileDescriptionModel: 'File Descriptions',
|
||||
imageDescriptionModel: 'Image Descriptions',
|
||||
commitMessageModel: 'Commit Messages',
|
||||
validationModel: 'GitHub Issue Validation',
|
||||
specGenerationModel: 'App Specification',
|
||||
featureGenerationModel: 'Feature Generation',
|
||||
backlogPlanningModel: 'Backlog Planning',
|
||||
projectAnalysisModel: 'Project Analysis',
|
||||
suggestionsModel: 'AI Suggestions',
|
||||
memoryExtractionModel: 'Memory Extraction',
|
||||
};
|
||||
|
||||
const ALL_PHASES = Object.keys(PHASE_LABELS) as PhaseModelKey[];
|
||||
|
||||
// Claude model display names
|
||||
const CLAUDE_MODEL_DISPLAY: Record<ClaudeModelAlias, string> = {
|
||||
haiku: 'Claude Haiku',
|
||||
sonnet: 'Claude Sonnet',
|
||||
opus: 'Claude Opus',
|
||||
};
|
||||
|
||||
export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps) {
|
||||
const { phaseModels, setPhaseModel, claudeCompatibleProviders } = useAppStore();
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('anthropic');
|
||||
|
||||
// Get enabled providers
|
||||
const enabledProviders = useMemo(() => {
|
||||
return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false);
|
||||
}, [claudeCompatibleProviders]);
|
||||
|
||||
// Build provider options for the dropdown
|
||||
const providerOptions = useMemo(() => {
|
||||
const options: Array<{ id: string; name: string; isNative: boolean }> = [
|
||||
{ id: 'anthropic', name: 'Anthropic Direct', isNative: true },
|
||||
];
|
||||
|
||||
enabledProviders.forEach((provider) => {
|
||||
options.push({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
isNative: false,
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [enabledProviders]);
|
||||
|
||||
// Get the selected provider config (if custom)
|
||||
const selectedProviderConfig = useMemo(() => {
|
||||
if (selectedProvider === 'anthropic') return null;
|
||||
return enabledProviders.find((p) => p.id === selectedProvider);
|
||||
}, [selectedProvider, enabledProviders]);
|
||||
|
||||
// Get the Claude model alias from a PhaseModelEntry
|
||||
const getClaudeModelAlias = (entry: PhaseModelEntry): ClaudeModelAlias => {
|
||||
// Check if model string directly matches a Claude alias
|
||||
if (entry.model === 'haiku' || entry.model === 'claude-haiku') return 'haiku';
|
||||
if (entry.model === 'sonnet' || entry.model === 'claude-sonnet') return 'sonnet';
|
||||
if (entry.model === 'opus' || entry.model === 'claude-opus') return 'opus';
|
||||
|
||||
// If it's a provider model, look up the mapping
|
||||
if (entry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === entry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === entry.model);
|
||||
if (model?.mapsToClaudeModel) {
|
||||
return model.mapsToClaudeModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to sonnet
|
||||
return 'sonnet';
|
||||
};
|
||||
|
||||
// Find the model from provider that maps to a specific Claude model
|
||||
const findModelForClaudeAlias = (
|
||||
provider: ClaudeCompatibleProvider | null,
|
||||
claudeAlias: ClaudeModelAlias
|
||||
): PhaseModelEntry => {
|
||||
if (!provider) {
|
||||
// Anthropic Direct - use Claude model directly
|
||||
return { model: claudeAlias };
|
||||
}
|
||||
|
||||
// Find model that maps to this Claude alias
|
||||
const models = provider.models || [];
|
||||
const match = models.find((m) => m.mapsToClaudeModel === claudeAlias);
|
||||
|
||||
if (match) {
|
||||
return { providerId: provider.id, model: match.id };
|
||||
}
|
||||
|
||||
// Fallback: use first model if no match
|
||||
if (models.length > 0) {
|
||||
return { providerId: provider.id, model: models[0].id };
|
||||
}
|
||||
|
||||
// Ultimate fallback to native Claude model
|
||||
return { model: claudeAlias };
|
||||
};
|
||||
|
||||
// Generate preview of changes
|
||||
const preview = useMemo(() => {
|
||||
return ALL_PHASES.map((phase) => {
|
||||
const currentEntry = phaseModels[phase] ?? DEFAULT_PHASE_MODELS[phase];
|
||||
const claudeAlias = getClaudeModelAlias(currentEntry);
|
||||
const newEntry = findModelForClaudeAlias(selectedProviderConfig, claudeAlias);
|
||||
|
||||
// Get display names
|
||||
const getCurrentDisplay = (): string => {
|
||||
if (currentEntry.providerId) {
|
||||
const provider = enabledProviders.find((p) => p.id === currentEntry.providerId);
|
||||
if (provider) {
|
||||
const model = provider.models?.find((m) => m.id === currentEntry.model);
|
||||
return model?.displayName || currentEntry.model;
|
||||
}
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[claudeAlias] || currentEntry.model;
|
||||
};
|
||||
|
||||
const getNewDisplay = (): string => {
|
||||
if (newEntry.providerId && selectedProviderConfig) {
|
||||
const model = selectedProviderConfig.models?.find((m) => m.id === newEntry.model);
|
||||
return model?.displayName || newEntry.model;
|
||||
}
|
||||
return CLAUDE_MODEL_DISPLAY[newEntry.model as ClaudeModelAlias] || newEntry.model;
|
||||
};
|
||||
|
||||
const isChanged =
|
||||
currentEntry.model !== newEntry.model || currentEntry.providerId !== newEntry.providerId;
|
||||
|
||||
return {
|
||||
phase,
|
||||
label: PHASE_LABELS[phase],
|
||||
claudeAlias,
|
||||
currentDisplay: getCurrentDisplay(),
|
||||
newDisplay: getNewDisplay(),
|
||||
newEntry,
|
||||
isChanged,
|
||||
};
|
||||
});
|
||||
}, [phaseModels, selectedProviderConfig, enabledProviders]);
|
||||
|
||||
// Count how many will change
|
||||
const changeCount = preview.filter((p) => p.isChanged).length;
|
||||
|
||||
// Apply the bulk replace
|
||||
const handleApply = () => {
|
||||
preview.forEach(({ phase, newEntry, isChanged }) => {
|
||||
if (isChanged) {
|
||||
setPhaseModel(phase, newEntry);
|
||||
}
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// Check if provider has all 3 Claude model mappings
|
||||
const providerModelCoverage = useMemo(() => {
|
||||
if (selectedProvider === 'anthropic') {
|
||||
return { hasHaiku: true, hasSonnet: true, hasOpus: true, complete: true };
|
||||
}
|
||||
if (!selectedProviderConfig) {
|
||||
return { hasHaiku: false, hasSonnet: false, hasOpus: false, complete: false };
|
||||
}
|
||||
const models = selectedProviderConfig.models || [];
|
||||
const hasHaiku = models.some((m) => m.mapsToClaudeModel === 'haiku');
|
||||
const hasSonnet = models.some((m) => m.mapsToClaudeModel === 'sonnet');
|
||||
const hasOpus = models.some((m) => m.mapsToClaudeModel === 'opus');
|
||||
return { hasHaiku, hasSonnet, hasOpus, complete: hasHaiku && hasSonnet && hasOpus };
|
||||
}, [selectedProvider, selectedProviderConfig]);
|
||||
|
||||
const providerHasModels =
|
||||
selectedProvider === 'anthropic' ||
|
||||
(selectedProviderConfig && selectedProviderConfig.models?.length > 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bulk Replace Models</DialogTitle>
|
||||
<DialogDescription>
|
||||
Switch all phase models to equivalents from a specific provider. Models are matched by
|
||||
their Claude model mapping (Haiku, Sonnet, Opus).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Provider selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Target Provider</label>
|
||||
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerOptions.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{option.isNative ? (
|
||||
<Cloud className="w-4 h-4 text-brand-500" />
|
||||
) : (
|
||||
<Server className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<span>{option.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Warning if provider has no models */}
|
||||
{!providerHasModels && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>This provider has no models configured.</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning if provider doesn't have all 3 mappings */}
|
||||
{providerHasModels && !providerModelCoverage.complete && (
|
||||
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20 text-sm">
|
||||
<div className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>
|
||||
This provider is missing mappings for:{' '}
|
||||
{[
|
||||
!providerModelCoverage.hasHaiku && 'Haiku',
|
||||
!providerModelCoverage.hasSonnet && 'Sonnet',
|
||||
!providerModelCoverage.hasOpus && 'Opus',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview of changes */}
|
||||
{providerHasModels && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Preview Changes</label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{changeCount} of {ALL_PHASES.length} will change
|
||||
</span>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left p-2 font-medium text-muted-foreground">Phase</th>
|
||||
<th className="text-left p-2 font-medium text-muted-foreground">Current</th>
|
||||
<th className="p-2"></th>
|
||||
<th className="text-left p-2 font-medium text-muted-foreground">New</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.map(({ phase, label, currentDisplay, newDisplay, isChanged }) => (
|
||||
<tr
|
||||
key={phase}
|
||||
className={cn(
|
||||
'border-t border-border/50',
|
||||
isChanged ? 'bg-brand-500/5' : 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<td className="p-2 font-medium">{label}</td>
|
||||
<td className="p-2 text-muted-foreground">{currentDisplay}</td>
|
||||
<td className="p-2 text-center">
|
||||
{isChanged ? (
|
||||
<ArrowRight className="w-4 h-4 text-brand-500 inline" />
|
||||
) : (
|
||||
<Check className="w-4 h-4 text-green-500 inline" />
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className={cn(isChanged && 'text-brand-500 font-medium')}>
|
||||
{newDisplay}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleApply} disabled={!providerHasModels || changeCount === 0}>
|
||||
Apply Changes ({changeCount})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Workflow, RotateCcw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Workflow, RotateCcw, Replace } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PhaseModelSelector } from './phase-model-selector';
|
||||
import { BulkReplaceDialog } from './bulk-replace-dialog';
|
||||
import type { PhaseModelKey } from '@automaker/types';
|
||||
import { DEFAULT_PHASE_MODELS } from '@automaker/types';
|
||||
|
||||
@@ -112,7 +114,12 @@ function PhaseGroup({
|
||||
}
|
||||
|
||||
export function ModelDefaultsSection() {
|
||||
const { resetPhaseModels } = useAppStore();
|
||||
const { resetPhaseModels, claudeCompatibleProviders } = useAppStore();
|
||||
const [showBulkReplace, setShowBulkReplace] = useState(false);
|
||||
|
||||
// Check if there are any enabled ClaudeCompatibleProviders
|
||||
const hasEnabledProviders =
|
||||
claudeCompatibleProviders && claudeCompatibleProviders.some((p) => p.enabled !== false);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -139,13 +146,29 @@ export function ModelDefaultsSection() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={resetPhaseModels} className="gap-2">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasEnabledProviders && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowBulkReplace(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Replace className="w-3.5 h-3.5" />
|
||||
Bulk Replace
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={resetPhaseModels} className="gap-2">
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Replace Dialog */}
|
||||
<BulkReplaceDialog open={showBulkReplace} onOpenChange={setShowBulkReplace} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-8">
|
||||
{/* Quick Tasks */}
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
OpencodeModelId,
|
||||
GroupedModel,
|
||||
PhaseModelEntry,
|
||||
ClaudeCompatibleProvider,
|
||||
ProviderModel,
|
||||
ClaudeModelAlias,
|
||||
} from '@automaker/types';
|
||||
import {
|
||||
stripProviderPrefix,
|
||||
@@ -33,6 +36,9 @@ import {
|
||||
AnthropicIcon,
|
||||
CursorIcon,
|
||||
OpenAIIcon,
|
||||
OpenRouterIcon,
|
||||
GlmIcon,
|
||||
MiniMaxIcon,
|
||||
getProviderIconForModel,
|
||||
} from '@/components/ui/provider-icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -154,10 +160,12 @@ export function PhaseModelSelector({
|
||||
const [expandedGroup, setExpandedGroup] = useState<string | null>(null);
|
||||
const [expandedClaudeModel, setExpandedClaudeModel] = useState<ModelAlias | null>(null);
|
||||
const [expandedCodexModel, setExpandedCodexModel] = useState<CodexModelId | null>(null);
|
||||
const [expandedProviderModel, setExpandedProviderModel] = useState<string | null>(null); // Format: providerId:modelId
|
||||
const commandListRef = useRef<HTMLDivElement>(null);
|
||||
const expandedTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const expandedClaudeTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const expandedCodexTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const expandedProviderTriggerRef = useRef<HTMLDivElement>(null);
|
||||
const {
|
||||
enabledCursorModels,
|
||||
favoriteModels,
|
||||
@@ -170,16 +178,23 @@ export function PhaseModelSelector({
|
||||
opencodeModelsLoading,
|
||||
fetchOpencodeModels,
|
||||
disabledProviders,
|
||||
claudeCompatibleProviders,
|
||||
} = useAppStore();
|
||||
|
||||
// Detect mobile devices to use inline expansion instead of nested popovers
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Extract model and thinking/reasoning levels from value
|
||||
// Extract model, provider, and thinking/reasoning levels from value
|
||||
const selectedModel = value.model;
|
||||
const selectedProviderId = value.providerId;
|
||||
const selectedThinkingLevel = value.thinkingLevel || 'none';
|
||||
const selectedReasoningEffort = value.reasoningEffort || 'none';
|
||||
|
||||
// Get enabled providers and their models
|
||||
const enabledProviders = useMemo(() => {
|
||||
return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false);
|
||||
}, [claudeCompatibleProviders]);
|
||||
|
||||
// Fetch Codex models on mount
|
||||
useEffect(() => {
|
||||
if (codexModels.length === 0 && !codexModelsLoading) {
|
||||
@@ -267,6 +282,29 @@ export function PhaseModelSelector({
|
||||
return () => observer.disconnect();
|
||||
}, [expandedCodexModel]);
|
||||
|
||||
// Close expanded provider model popover when trigger scrolls out of view
|
||||
useEffect(() => {
|
||||
const triggerElement = expandedProviderTriggerRef.current;
|
||||
const listElement = commandListRef.current;
|
||||
if (!triggerElement || !listElement || !expandedProviderModel) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry.isIntersecting) {
|
||||
setExpandedProviderModel(null);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: listElement,
|
||||
threshold: 0.1,
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(triggerElement);
|
||||
return () => observer.disconnect();
|
||||
}, [expandedProviderModel]);
|
||||
|
||||
// Transform dynamic Codex models from store to component format
|
||||
const transformedCodexModels = useMemo(() => {
|
||||
return codexModels.map((model) => ({
|
||||
@@ -337,13 +375,55 @@ export function PhaseModelSelector({
|
||||
};
|
||||
}
|
||||
|
||||
// Check ClaudeCompatibleProvider models (when providerId is set)
|
||||
if (selectedProviderId) {
|
||||
const provider = enabledProviders.find((p) => p.id === selectedProviderId);
|
||||
if (provider) {
|
||||
const providerModel = provider.models?.find((m) => m.id === selectedModel);
|
||||
if (providerModel) {
|
||||
// Count providers of same type to determine if we need provider name suffix
|
||||
const sameTypeCount = enabledProviders.filter(
|
||||
(p) => p.providerType === provider.providerType
|
||||
).length;
|
||||
const suffix = sameTypeCount > 1 ? ` (${provider.name})` : '';
|
||||
// Add thinking level to label if not 'none'
|
||||
const thinkingLabel =
|
||||
selectedThinkingLevel !== 'none'
|
||||
? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)`
|
||||
: '';
|
||||
// Get icon based on provider type
|
||||
const getIconForProviderType = () => {
|
||||
switch (provider.providerType) {
|
||||
case 'glm':
|
||||
return GlmIcon;
|
||||
case 'minimax':
|
||||
return MiniMaxIcon;
|
||||
case 'openrouter':
|
||||
return OpenRouterIcon;
|
||||
default:
|
||||
return getProviderIconForModel(providerModel.id) || OpenRouterIcon;
|
||||
}
|
||||
};
|
||||
return {
|
||||
id: selectedModel,
|
||||
label: `${providerModel.displayName}${suffix}${thinkingLabel}`,
|
||||
description: provider.name,
|
||||
provider: 'claude-compatible' as const,
|
||||
icon: getIconForProviderType(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
selectedThinkingLevel,
|
||||
availableCursorModels,
|
||||
transformedCodexModels,
|
||||
dynamicOpencodeModels,
|
||||
enabledProviders,
|
||||
]);
|
||||
|
||||
// Compute grouped vs standalone Cursor models
|
||||
@@ -907,6 +987,245 @@ export function PhaseModelSelector({
|
||||
);
|
||||
};
|
||||
|
||||
// Render ClaudeCompatibleProvider model item with thinking level support
|
||||
const renderProviderModelItem = (
|
||||
provider: ClaudeCompatibleProvider,
|
||||
model: ProviderModel,
|
||||
showProviderSuffix: boolean,
|
||||
allMappedModels: ClaudeModelAlias[] = []
|
||||
) => {
|
||||
const isSelected = selectedModel === model.id && selectedProviderId === provider.id;
|
||||
const expandKey = `${provider.id}:${model.id}`;
|
||||
const isExpanded = expandedProviderModel === expandKey;
|
||||
const currentThinking = isSelected ? selectedThinkingLevel : 'none';
|
||||
const displayName = showProviderSuffix
|
||||
? `${model.displayName} (${provider.name})`
|
||||
: model.displayName;
|
||||
|
||||
// Build description showing all mapped Claude models
|
||||
const modelLabelMap: Record<ClaudeModelAlias, string> = {
|
||||
haiku: 'Haiku',
|
||||
sonnet: 'Sonnet',
|
||||
opus: 'Opus',
|
||||
};
|
||||
// Sort in order: haiku, sonnet, opus for consistent display
|
||||
const sortOrder: ClaudeModelAlias[] = ['haiku', 'sonnet', 'opus'];
|
||||
const sortedMappedModels = [...allMappedModels].sort(
|
||||
(a, b) => sortOrder.indexOf(a) - sortOrder.indexOf(b)
|
||||
);
|
||||
const mappedModelLabel =
|
||||
sortedMappedModels.length > 0
|
||||
? sortedMappedModels.map((m) => modelLabelMap[m]).join(', ')
|
||||
: 'Claude';
|
||||
|
||||
// Get icon based on provider type, falling back to model-based detection
|
||||
const getProviderTypeIcon = () => {
|
||||
switch (provider.providerType) {
|
||||
case 'glm':
|
||||
return GlmIcon;
|
||||
case 'minimax':
|
||||
return MiniMaxIcon;
|
||||
case 'openrouter':
|
||||
return OpenRouterIcon;
|
||||
default:
|
||||
// For generic/unknown providers, use OpenRouter as a generic "cloud API" icon
|
||||
// unless the model ID has a recognizable pattern
|
||||
return getProviderIconForModel(model.id) || OpenRouterIcon;
|
||||
}
|
||||
};
|
||||
const ProviderIcon = getProviderTypeIcon();
|
||||
|
||||
// On mobile, render inline expansion instead of nested popover
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div key={`${provider.id}-${model.id}`}>
|
||||
<CommandItem
|
||||
value={`${provider.name} ${model.displayName}`}
|
||||
onSelect={() => setExpandedProviderModel(isExpanded ? null : expandKey)}
|
||||
className="group flex items-center justify-between py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<ProviderIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{isSelected && currentThinking !== 'none'
|
||||
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
|
||||
: `Maps to ${mappedModelLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{isSelected && !isExpanded && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
||||
{/* Inline thinking level options on mobile */}
|
||||
{isExpanded && (
|
||||
<div className="pl-6 pr-2 pb-2 space-y-1">
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
onChange({
|
||||
providerId: provider.id,
|
||||
model: model.id,
|
||||
thinkingLevel: level,
|
||||
});
|
||||
setExpandedProviderModel(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||
'hover:bg-accent cursor-pointer transition-colors',
|
||||
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium text-xs">{THINKING_LEVEL_LABELS[level]}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{level === 'none' && 'No extended thinking'}
|
||||
{level === 'low' && 'Light reasoning (1k tokens)'}
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop: Use nested popover
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${provider.id}-${model.id}`}
|
||||
value={`${provider.name} ${model.displayName}`}
|
||||
onSelect={() => setExpandedProviderModel(isExpanded ? null : expandKey)}
|
||||
className="p-0 data-[selected=true]:bg-transparent"
|
||||
>
|
||||
<Popover
|
||||
open={isExpanded}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setExpandedProviderModel(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
ref={isExpanded ? expandedProviderTriggerRef : undefined}
|
||||
className={cn(
|
||||
'w-full group flex items-center justify-between py-2 px-2 rounded-sm cursor-pointer',
|
||||
'hover:bg-accent',
|
||||
isExpanded && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<ProviderIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{isSelected && currentThinking !== 'none'
|
||||
? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}`
|
||||
: `Maps to ${mappedModelLabel}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
className="w-[220px] p-1"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border/50 mb-1">
|
||||
Thinking Level
|
||||
</div>
|
||||
{THINKING_LEVELS.map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => {
|
||||
onChange({
|
||||
providerId: provider.id,
|
||||
model: model.id,
|
||||
thinkingLevel: level,
|
||||
});
|
||||
setExpandedProviderModel(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-2 py-2 rounded-sm text-sm',
|
||||
'hover:bg-accent cursor-pointer transition-colors',
|
||||
isSelected && currentThinking === level && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="font-medium">{THINKING_LEVEL_LABELS[level]}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{level === 'none' && 'No extended thinking'}
|
||||
{level === 'low' && 'Light reasoning (1k tokens)'}
|
||||
{level === 'medium' && 'Moderate reasoning (10k tokens)'}
|
||||
{level === 'high' && 'Deep reasoning (16k tokens)'}
|
||||
{level === 'ultrathink' && 'Maximum reasoning (32k tokens)'}
|
||||
</span>
|
||||
</div>
|
||||
{isSelected && currentThinking === level && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
// Render Cursor model item (no thinking level needed)
|
||||
const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => {
|
||||
// With canonical IDs, store the full prefixed ID
|
||||
@@ -1499,6 +1818,50 @@ export function PhaseModelSelector({
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{/* ClaudeCompatibleProvider Models - each provider as separate group */}
|
||||
{enabledProviders.map((provider) => {
|
||||
if (!provider.models || provider.models.length === 0) return null;
|
||||
|
||||
// Check if we need provider suffix (multiple providers of same type)
|
||||
const sameTypeCount = enabledProviders.filter(
|
||||
(p) => p.providerType === provider.providerType
|
||||
).length;
|
||||
const showSuffix = sameTypeCount > 1;
|
||||
|
||||
// Group models by ID and collect all mapped Claude models for each
|
||||
const modelsByIdMap = new Map<
|
||||
string,
|
||||
{ model: ProviderModel; mappedModels: ClaudeModelAlias[] }
|
||||
>();
|
||||
for (const model of provider.models) {
|
||||
const existing = modelsByIdMap.get(model.id);
|
||||
if (existing) {
|
||||
// Add this mapped model if not already present
|
||||
if (
|
||||
model.mapsToClaudeModel &&
|
||||
!existing.mappedModels.includes(model.mapsToClaudeModel)
|
||||
) {
|
||||
existing.mappedModels.push(model.mapsToClaudeModel);
|
||||
}
|
||||
} else {
|
||||
// First occurrence of this model ID
|
||||
modelsByIdMap.set(model.id, {
|
||||
model,
|
||||
mappedModels: model.mapsToClaudeModel ? [model.mapsToClaudeModel] : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
const uniqueModelsWithMappings = Array.from(modelsByIdMap.values());
|
||||
|
||||
return (
|
||||
<CommandGroup key={provider.id} heading={`${provider.name} (via Claude)`}>
|
||||
{uniqueModelsWithMappings.map(({ model, mappedModels }) =>
|
||||
renderProviderModelItem(provider, model, showSuffix, mappedModels)
|
||||
)}
|
||||
</CommandGroup>
|
||||
);
|
||||
})}
|
||||
|
||||
{(groupedModels.length > 0 || standaloneCursorModels.length > 0) && (
|
||||
<CommandGroup heading="Cursor Models">
|
||||
{/* Grouped models with secondary popover */}
|
||||
|
||||
Reference in New Issue
Block a user