mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
feat: unified Claude API key and profile system with z.AI, MiniMax, OpenRouter support (#600)
* feat: add Claude API provider profiles for alternative endpoints Add support for managing multiple Claude-compatible API endpoints (z.AI GLM, AWS Bedrock, etc.) through provider profiles in settings. Features: - New ClaudeApiProfile type with base URL, API key, model mappings - Pre-configured z.AI GLM template with correct model names - Profile selector in Settings > Claude > API Profiles - Clean switching between profiles and direct Anthropic API - Immediate persistence to prevent data loss on restart Profile support added to all execution paths: - Agent service (chat) - Ideation service - Auto-mode service (feature agents, enhancements) - Simple query service (title generation, descriptions, etc.) - Backlog planning, commit messages, spec generation - GitHub issue validation, suggestions Environment variables set when profile is active: - ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN/API_KEY - ANTHROPIC_DEFAULT_HAIKU/SONNET/OPUS_MODEL - API_TIMEOUT_MS, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
This commit is contained in:
committed by
GitHub
parent
63b8eb0991
commit
d97c4b7b57
@@ -212,7 +212,7 @@ export function useBoardActions({
|
||||
const api = getElectronAPI();
|
||||
if (api?.features?.generateTitle) {
|
||||
api.features
|
||||
.generateTitle(featureData.description)
|
||||
.generateTitle(featureData.description, projectPath ?? undefined)
|
||||
.then((result) => {
|
||||
if (result.success && result.title) {
|
||||
const titleUpdates = {
|
||||
@@ -245,6 +245,7 @@ export function useBoardActions({
|
||||
updateFeature,
|
||||
saveCategory,
|
||||
currentProject,
|
||||
projectPath,
|
||||
onWorktreeCreated,
|
||||
onWorktreeAutoSelect,
|
||||
getPrimaryWorktreeBranch,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { toast } from 'sonner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
|
||||
import { EnhancementMode, ENHANCEMENT_MODE_LABELS } from './enhancement-constants';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
const logger = createLogger('EnhanceWithAI');
|
||||
|
||||
@@ -56,6 +57,9 @@ export function EnhanceWithAI({
|
||||
const [enhancementMode, setEnhancementMode] = useState<EnhancementMode>('improve');
|
||||
const [enhanceOpen, setEnhanceOpen] = useState(false);
|
||||
|
||||
// Get current project path for per-project Claude API profile
|
||||
const currentProjectPath = useAppStore((state) => state.currentProject?.path);
|
||||
|
||||
// Enhancement model override
|
||||
const enhancementOverride = useModelOverride({ phase: 'enhancementModel' });
|
||||
|
||||
@@ -69,7 +73,8 @@ export function EnhanceWithAI({
|
||||
value,
|
||||
enhancementMode,
|
||||
enhancementOverride.effectiveModel,
|
||||
enhancementOverride.effectiveModelEntry.thinkingLevel
|
||||
enhancementOverride.effectiveModelEntry.thinkingLevel,
|
||||
currentProjectPath
|
||||
);
|
||||
|
||||
if (result?.success && result.enhancedText) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle } from 'lucide-react';
|
||||
import { User, GitBranch, Palette, AlertTriangle, Bot } from 'lucide-react';
|
||||
import type { ProjectSettingsViewId } from '../hooks/use-project-settings-view';
|
||||
|
||||
export interface ProjectNavigationItem {
|
||||
@@ -12,5 +12,6 @@ export const PROJECT_SETTINGS_NAV_ITEMS: ProjectNavigationItem[] = [
|
||||
{ id: 'identity', label: 'Identity', icon: User },
|
||||
{ id: 'worktrees', label: 'Worktrees', icon: GitBranch },
|
||||
{ id: 'theme', label: 'Theme', icon: Palette },
|
||||
{ id: 'claude', label: 'Claude', icon: Bot },
|
||||
{ id: 'danger', label: 'Danger Zone', icon: AlertTriangle },
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'danger';
|
||||
export type ProjectSettingsViewId = 'identity' | 'theme' | 'worktrees' | 'claude' | 'danger';
|
||||
|
||||
interface UseProjectSettingsViewOptions {
|
||||
initialView?: ProjectSettingsViewId;
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Bot, Cloud, Server, Globe } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Project } from '@/lib/electron';
|
||||
|
||||
interface ProjectClaudeSectionProps {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
export function ProjectClaudeSection({ project }: ProjectClaudeSectionProps) {
|
||||
const {
|
||||
claudeApiProfiles,
|
||||
activeClaudeApiProfileId: globalActiveProfileId,
|
||||
disabledProviders,
|
||||
setProjectClaudeApiProfile,
|
||||
} = useAppStore();
|
||||
const { claudeAuthStatus } = useSetupStore();
|
||||
|
||||
// Get project-level override from project
|
||||
const projectActiveProfileId = project.activeClaudeApiProfileId;
|
||||
|
||||
// Determine effective value for display
|
||||
// undefined = use global, null = explicit direct, string = specific profile
|
||||
const selectValue =
|
||||
projectActiveProfileId === undefined
|
||||
? 'global'
|
||||
: projectActiveProfileId === null
|
||||
? 'direct'
|
||||
: projectActiveProfileId;
|
||||
|
||||
// Check if Claude is available
|
||||
const isClaudeDisabled = disabledProviders.includes('claude');
|
||||
const hasProfiles = claudeApiProfiles.length > 0;
|
||||
const isClaudeAuthenticated = claudeAuthStatus?.authenticated;
|
||||
|
||||
// Get global profile name for display
|
||||
const globalProfile = globalActiveProfileId
|
||||
? claudeApiProfiles.find((p) => p.id === globalActiveProfileId)
|
||||
: null;
|
||||
const globalProfileName = globalProfile?.name || 'Direct Anthropic API';
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
// 'global' -> undefined (use global)
|
||||
// 'direct' -> null (explicit direct)
|
||||
// profile id -> string (specific profile)
|
||||
const newValue = value === 'global' ? undefined : value === 'direct' ? null : value;
|
||||
setProjectClaudeApiProfile(project.id, newValue);
|
||||
};
|
||||
|
||||
// Don't render if Claude is disabled or not available
|
||||
if (isClaudeDisabled || (!hasProfiles && !isClaudeAuthenticated)) {
|
||||
return (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Bot className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">Claude not configured</p>
|
||||
<p className="text-xs mt-1">
|
||||
Enable Claude and configure API profiles in global settings to use per-project profiles.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get the display text for current selection
|
||||
const getDisplayText = () => {
|
||||
if (selectValue === 'global') {
|
||||
return `Using global setting: ${globalProfileName}`;
|
||||
}
|
||||
if (selectValue === 'direct') {
|
||||
return 'Using direct Anthropic API (API key or Claude Max plan)';
|
||||
}
|
||||
const selectedProfile = claudeApiProfiles.find((p) => p.id === selectValue);
|
||||
return `Using ${selectedProfile?.name || 'custom'} endpoint`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Bot className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Claude API Profile
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||
Override the Claude API profile for this project only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Active Profile for This Project</Label>
|
||||
<Select value={selectValue} onValueChange={handleChange}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select profile" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="global">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-4 h-4 text-muted-foreground" />
|
||||
<span>Use Global Setting</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">({globalProfileName})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="direct">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4 text-brand-500" />
|
||||
<span>Direct Anthropic API</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{claudeApiProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{profile.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{getDisplayText()}</p>
|
||||
</div>
|
||||
|
||||
{/* Info about what this affects */}
|
||||
<div className="text-xs text-muted-foreground/70 pt-2 border-t border-border/30">
|
||||
<p>This setting affects all Claude operations for this project including:</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-0.5">
|
||||
<li>Agent chat and feature implementation</li>
|
||||
<li>Code analysis and suggestions</li>
|
||||
<li>Commit message generation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { ProjectIdentitySection } from './project-identity-section';
|
||||
import { ProjectThemeSection } from './project-theme-section';
|
||||
import { WorktreePreferencesSection } from './worktree-preferences-section';
|
||||
import { ProjectClaudeSection } from './project-claude-section';
|
||||
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
|
||||
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
|
||||
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
|
||||
@@ -84,6 +85,8 @@ export function ProjectSettingsView() {
|
||||
return <ProjectThemeSection project={currentProject} />;
|
||||
case 'worktrees':
|
||||
return <WorktreePreferencesSection project={currentProject} />;
|
||||
case 'claude':
|
||||
return <ProjectClaudeSection project={currentProject} />;
|
||||
case 'danger':
|
||||
return (
|
||||
<DangerZoneSection
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Key, CheckCircle2, Trash2 } from 'lucide-react';
|
||||
import { Key, CheckCircle2, Trash2, Info } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { ApiKeyField } from './api-key-field';
|
||||
import { buildProviderConfigs } from '@/config/api-providers';
|
||||
@@ -101,9 +101,38 @@ export function ApiKeysSection() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* API Key Fields */}
|
||||
{/* API Key Fields with contextual info */}
|
||||
{providerConfigs.map((provider) => (
|
||||
<ApiKeyField key={provider.key} config={provider} />
|
||||
<div key={provider.key}>
|
||||
<ApiKeyField config={provider} />
|
||||
{/* Anthropic-specific profile info */}
|
||||
{provider.key === 'anthropic' && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-blue-500/5 border border-blue-500/20">
|
||||
<div className="flex gap-2">
|
||||
<Info className="w-4 h-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p>
|
||||
<span className="font-medium text-foreground/80">
|
||||
Using Claude API Profiles?
|
||||
</span>{' '}
|
||||
Create a profile in{' '}
|
||||
<span className="text-blue-500">AI Providers → Claude</span> with{' '}
|
||||
<span className="font-mono text-[10px] bg-muted/50 px-1 rounded">
|
||||
credentials
|
||||
</span>{' '}
|
||||
as the API key source to use this key.
|
||||
</p>
|
||||
<p>
|
||||
For alternative providers (z.AI GLM, MiniMax, OpenRouter), create a profile
|
||||
with{' '}
|
||||
<span className="font-mono text-[10px] bg-muted/50 px-1 rounded">inline</span>{' '}
|
||||
key source and enter the provider's API key directly in the profile.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Security Notice */}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ClaudeMdSettings } from '../claude/claude-md-settings';
|
||||
import { ClaudeUsageSection } from '../api-keys/claude-usage-section';
|
||||
import { SkillsSection } from './claude-settings-tab/skills-section';
|
||||
import { SubagentsSection } from './claude-settings-tab/subagents-section';
|
||||
import { ApiProfilesSection } from './claude-settings-tab/api-profiles-section';
|
||||
import { ProviderToggle } from './provider-toggle';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
@@ -45,6 +46,10 @@ export function ClaudeSettingsTab() {
|
||||
isChecking={isCheckingClaudeCli}
|
||||
onRefresh={handleRefreshClaudeCli}
|
||||
/>
|
||||
|
||||
{/* API Profiles for Claude-compatible endpoints */}
|
||||
<ApiProfilesSection />
|
||||
|
||||
<ClaudeMdSettings
|
||||
autoLoadClaudeMd={autoLoadClaudeMd}
|
||||
onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Cloud,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ExternalLink,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Server,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { ClaudeApiProfile, ApiKeySource } from '@automaker/types';
|
||||
import { CLAUDE_API_PROFILE_TEMPLATES } from '@automaker/types';
|
||||
|
||||
// Generate unique ID for profiles
|
||||
function generateProfileId(): string {
|
||||
return `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Mask API key for display (show first 4 + last 4 chars)
|
||||
function maskApiKey(key?: string): string {
|
||||
if (!key || key.length <= 8) return '••••••••';
|
||||
return `${key.substring(0, 4)}••••${key.substring(key.length - 4)}`;
|
||||
}
|
||||
|
||||
interface ProfileFormData {
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKeySource: ApiKeySource;
|
||||
apiKey: string;
|
||||
useAuthToken: boolean;
|
||||
timeoutMs: string; // String for input, convert to number
|
||||
modelMappings: {
|
||||
haiku: string;
|
||||
sonnet: string;
|
||||
opus: string;
|
||||
};
|
||||
disableNonessentialTraffic: boolean;
|
||||
}
|
||||
|
||||
const emptyFormData: ProfileFormData = {
|
||||
name: '',
|
||||
baseUrl: '',
|
||||
apiKeySource: 'inline',
|
||||
apiKey: '',
|
||||
useAuthToken: false,
|
||||
timeoutMs: '',
|
||||
modelMappings: {
|
||||
haiku: '',
|
||||
sonnet: '',
|
||||
opus: '',
|
||||
},
|
||||
disableNonessentialTraffic: false,
|
||||
};
|
||||
|
||||
export function ApiProfilesSection() {
|
||||
const {
|
||||
claudeApiProfiles,
|
||||
activeClaudeApiProfileId,
|
||||
addClaudeApiProfile,
|
||||
updateClaudeApiProfile,
|
||||
deleteClaudeApiProfile,
|
||||
setActiveClaudeApiProfile,
|
||||
} = useAppStore();
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingProfileId, setEditingProfileId] = useState<string | null>(null);
|
||||
const [formData, setFormData] = useState<ProfileFormData>(emptyFormData);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
const [currentTemplate, setCurrentTemplate] = useState<
|
||||
(typeof CLAUDE_API_PROFILE_TEMPLATES)[0] | null
|
||||
>(null);
|
||||
|
||||
const handleOpenAddDialog = (templateName?: string) => {
|
||||
const template = templateName
|
||||
? CLAUDE_API_PROFILE_TEMPLATES.find((t) => t.name === templateName)
|
||||
: undefined;
|
||||
|
||||
if (template) {
|
||||
setFormData({
|
||||
name: template.name,
|
||||
baseUrl: template.baseUrl,
|
||||
apiKeySource: template.defaultApiKeySource ?? 'inline',
|
||||
apiKey: '',
|
||||
useAuthToken: template.useAuthToken,
|
||||
timeoutMs: template.timeoutMs?.toString() ?? '',
|
||||
modelMappings: {
|
||||
haiku: template.modelMappings?.haiku ?? '',
|
||||
sonnet: template.modelMappings?.sonnet ?? '',
|
||||
opus: template.modelMappings?.opus ?? '',
|
||||
},
|
||||
disableNonessentialTraffic: template.disableNonessentialTraffic ?? false,
|
||||
});
|
||||
setCurrentTemplate(template);
|
||||
} else {
|
||||
setFormData(emptyFormData);
|
||||
setCurrentTemplate(null);
|
||||
}
|
||||
|
||||
setEditingProfileId(null);
|
||||
setShowApiKey(false);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditDialog = (profile: ClaudeApiProfile) => {
|
||||
// Find matching template by base URL
|
||||
const template = CLAUDE_API_PROFILE_TEMPLATES.find((t) => t.baseUrl === profile.baseUrl);
|
||||
|
||||
setFormData({
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
apiKeySource: profile.apiKeySource ?? 'inline',
|
||||
apiKey: profile.apiKey ?? '',
|
||||
useAuthToken: profile.useAuthToken ?? false,
|
||||
timeoutMs: profile.timeoutMs?.toString() ?? '',
|
||||
modelMappings: {
|
||||
haiku: profile.modelMappings?.haiku ?? '',
|
||||
sonnet: profile.modelMappings?.sonnet ?? '',
|
||||
opus: profile.modelMappings?.opus ?? '',
|
||||
},
|
||||
disableNonessentialTraffic: profile.disableNonessentialTraffic ?? false,
|
||||
});
|
||||
setEditingProfileId(profile.id);
|
||||
setCurrentTemplate(template ?? null);
|
||||
setShowApiKey(false);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const profileData: ClaudeApiProfile = {
|
||||
id: editingProfileId ?? generateProfileId(),
|
||||
name: formData.name.trim(),
|
||||
baseUrl: formData.baseUrl.trim(),
|
||||
apiKeySource: formData.apiKeySource,
|
||||
// Only include apiKey when source is 'inline'
|
||||
apiKey: formData.apiKeySource === 'inline' ? formData.apiKey : undefined,
|
||||
useAuthToken: formData.useAuthToken,
|
||||
timeoutMs: (() => {
|
||||
const parsed = Number(formData.timeoutMs);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
})(),
|
||||
modelMappings:
|
||||
formData.modelMappings.haiku || formData.modelMappings.sonnet || formData.modelMappings.opus
|
||||
? {
|
||||
...(formData.modelMappings.haiku && { haiku: formData.modelMappings.haiku }),
|
||||
...(formData.modelMappings.sonnet && { sonnet: formData.modelMappings.sonnet }),
|
||||
...(formData.modelMappings.opus && { opus: formData.modelMappings.opus }),
|
||||
}
|
||||
: undefined,
|
||||
disableNonessentialTraffic: formData.disableNonessentialTraffic || undefined,
|
||||
};
|
||||
|
||||
if (editingProfileId) {
|
||||
updateClaudeApiProfile(editingProfileId, profileData);
|
||||
} else {
|
||||
addClaudeApiProfile(profileData);
|
||||
}
|
||||
|
||||
setIsDialogOpen(false);
|
||||
setFormData(emptyFormData);
|
||||
setEditingProfileId(null);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
deleteClaudeApiProfile(id);
|
||||
setDeleteConfirmId(null);
|
||||
};
|
||||
|
||||
// Check for duplicate profile name (case-insensitive, excluding current profile when editing)
|
||||
const isDuplicateName = claudeApiProfiles.some(
|
||||
(p) => p.name.toLowerCase() === formData.name.trim().toLowerCase() && p.id !== editingProfileId
|
||||
);
|
||||
|
||||
// API key is only required when source is 'inline'
|
||||
const isFormValid =
|
||||
formData.name.trim().length > 0 &&
|
||||
formData.baseUrl.trim().length > 0 &&
|
||||
(formData.apiKeySource !== 'inline' || formData.apiKey.length > 0) &&
|
||||
!isDuplicateName;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-linear-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-brand-500/10">
|
||||
<Server className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">API Profiles</h3>
|
||||
<p className="text-xs text-muted-foreground">Manage Claude-compatible API endpoints</p>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Profile
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleOpenAddDialog()}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Custom Profile
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{CLAUDE_API_PROFILE_TEMPLATES.map((template) => (
|
||||
<DropdownMenuItem
|
||||
key={template.name}
|
||||
onClick={() => handleOpenAddDialog(template.name)}
|
||||
>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
{template.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Active Profile Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Active Profile</Label>
|
||||
<Select
|
||||
value={activeClaudeApiProfileId ?? 'none'}
|
||||
onValueChange={(value) => setActiveClaudeApiProfile(value === 'none' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select active profile" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="w-4 h-4 text-brand-500" />
|
||||
Direct Anthropic API
|
||||
</div>
|
||||
</SelectItem>
|
||||
{claudeApiProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-muted-foreground" />
|
||||
{profile.name}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeClaudeApiProfileId
|
||||
? 'Using custom API endpoint'
|
||||
: 'Using direct Anthropic API (API key or Claude Max plan)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile List */}
|
||||
{claudeApiProfiles.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground border border-dashed border-border/50 rounded-lg">
|
||||
<Server className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">No API profiles configured</p>
|
||||
<p className="text-xs mt-1">
|
||||
Add a profile to use alternative Claude-compatible endpoints
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{claudeApiProfiles.map((profile) => (
|
||||
<ProfileCard
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
isActive={profile.id === activeClaudeApiProfileId}
|
||||
onEdit={() => handleOpenEditDialog(profile)}
|
||||
onDelete={() => setDeleteConfirmId(profile.id)}
|
||||
onSetActive={() => setActiveClaudeApiProfile(profile.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add/Edit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingProfileId ? 'Edit API Profile' : 'Add API Profile'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a Claude-compatible API endpoint. API keys are stored locally.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="e.g., z.AI GLM"
|
||||
className={isDuplicateName ? 'border-destructive' : ''}
|
||||
/>
|
||||
{isDuplicateName && (
|
||||
<p className="text-xs text-destructive">A profile with this name already exists</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-base-url">API Base URL</Label>
|
||||
<Input
|
||||
id="profile-base-url"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key Source */}
|
||||
<div className="space-y-2">
|
||||
<Label>API Key Source</Label>
|
||||
<Select
|
||||
value={formData.apiKeySource}
|
||||
onValueChange={(value: ApiKeySource) =>
|
||||
setFormData({ ...formData, apiKeySource: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select API key source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="credentials">
|
||||
Use saved API key (from Settings → API Keys)
|
||||
</SelectItem>
|
||||
<SelectItem value="env">Use environment variable (ANTHROPIC_API_KEY)</SelectItem>
|
||||
<SelectItem value="inline">Enter key for this profile only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.apiKeySource === 'credentials' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Will use the Anthropic key from Settings → API Keys
|
||||
</p>
|
||||
)}
|
||||
{formData.apiKeySource === 'env' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Will use ANTHROPIC_API_KEY environment variable
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key (only shown for inline source) */}
|
||||
{formData.apiKeySource === 'inline' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="profile-api-key"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
placeholder="Enter API key"
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3 text-muted-foreground hover:text-foreground hover:bg-transparent"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{currentTemplate?.apiKeyUrl && (
|
||||
<a
|
||||
href={currentTemplate.apiKeyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-brand-500 hover:text-brand-400"
|
||||
>
|
||||
Get API Key from {currentTemplate.name} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Use Auth Token */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<Label htmlFor="use-auth-token" className="font-medium">
|
||||
Use Auth Token
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use ANTHROPIC_AUTH_TOKEN instead of ANTHROPIC_API_KEY
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="use-auth-token"
|
||||
checked={formData.useAuthToken}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, useAuthToken: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timeout */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-timeout">Timeout (ms)</Label>
|
||||
<Input
|
||||
id="profile-timeout"
|
||||
type="number"
|
||||
value={formData.timeoutMs}
|
||||
onChange={(e) => setFormData({ ...formData, timeoutMs: e.target.value })}
|
||||
placeholder="Optional, e.g., 3000000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model Mappings */}
|
||||
<div className="space-y-3">
|
||||
<Label className="font-medium">Model Mappings (Optional)</Label>
|
||||
<p className="text-xs text-muted-foreground -mt-1">
|
||||
Map Claude model aliases to provider-specific model names
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="model-haiku" className="text-xs">
|
||||
Haiku
|
||||
</Label>
|
||||
<Input
|
||||
id="model-haiku"
|
||||
value={formData.modelMappings.haiku}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
modelMappings: { ...formData.modelMappings, haiku: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="e.g., GLM-4.5-Flash"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="model-sonnet" className="text-xs">
|
||||
Sonnet
|
||||
</Label>
|
||||
<Input
|
||||
id="model-sonnet"
|
||||
value={formData.modelMappings.sonnet}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
modelMappings: { ...formData.modelMappings, sonnet: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="e.g., glm-4.7"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="model-opus" className="text-xs">
|
||||
Opus
|
||||
</Label>
|
||||
<Input
|
||||
id="model-opus"
|
||||
value={formData.modelMappings.opus}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
modelMappings: { ...formData.modelMappings, opus: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="e.g., glm-4.7"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disable Non-essential Traffic */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<Label htmlFor="disable-traffic" className="font-medium">
|
||||
Disable Non-essential Traffic
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sets CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="disable-traffic"
|
||||
checked={formData.disableNonessentialTraffic}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, disableNonessentialTraffic: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!isFormValid}>
|
||||
{editingProfileId ? 'Save Changes' : 'Add Profile'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={!!deleteConfirmId} onOpenChange={(open) => !open && setDeleteConfirmId(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently delete the API profile. If this profile is currently active, you
|
||||
will be switched to direct Anthropic API.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirmId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteConfirmId && handleDelete(deleteConfirmId)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileCardProps {
|
||||
profile: ClaudeApiProfile;
|
||||
isActive: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onSetActive: () => void;
|
||||
}
|
||||
|
||||
function ProfileCard({ profile, isActive, onEdit, onDelete, onSetActive }: ProfileCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4 transition-colors',
|
||||
isActive
|
||||
? 'border-brand-500/50 bg-brand-500/5'
|
||||
: 'border-border/50 bg-card/50 hover:border-border'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium text-foreground truncate">{profile.name}</h4>
|
||||
{isActive && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-brand-500/20 text-brand-500">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">{profile.baseUrl}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 mt-2 text-xs text-muted-foreground">
|
||||
<span>Key: {maskApiKey(profile.apiKey)}</span>
|
||||
{profile.useAuthToken && <span>Auth Token</span>}
|
||||
{profile.timeoutMs && <span>Timeout: {(profile.timeoutMs / 1000).toFixed(0)}s</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="shrink-0">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!isActive && (
|
||||
<DropdownMenuItem onClick={onSetActive}>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
Set Active
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="w-4 h-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={onDelete}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user