Files
ccs/ui/src/components/cliproxy/provider-editor/index.tsx
T

378 lines
13 KiB
TypeScript

/**
* Provider Editor Component
* Split-view editor for CLIProxy provider settings
*/
/* eslint-disable react-refresh/only-export-components */
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Loader2, Code2 } from 'lucide-react';
import { toast } from 'sonner';
import {
useCliproxyModels,
usePresets,
useCreatePreset,
useDeletePreset,
} from '@/hooks/use-cliproxy';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { isDeniedAgyModelId } from '@/lib/utils';
import i18n from '@/lib/i18n';
import { usePrivacy } from '@/contexts/privacy-context';
import { useTranslation } from 'react-i18next';
import { useProviderEditor } from './use-provider-editor';
import { CustomPresetDialog } from './custom-preset-dialog';
import { RawEditorSection } from './raw-editor-section';
import { ProviderInfoTab } from './provider-info-tab';
import { ProviderEditorHeader } from './provider-editor-header';
import { ModelConfigTab } from './model-config-tab';
import type { ProviderEditorProps, ModelMappingValues } from './types';
export function ProviderEditor({
provider,
displayName,
authStatus,
catalog,
routing,
logoProvider,
baseProvider,
isRemoteMode,
port,
defaultTarget,
topNotice,
onAddAccount,
onSetDefault,
onRemoveAccount,
onPauseToggle,
onSoloMode,
onBulkPause,
onBulkResume,
isRemovingAccount,
isPausingAccount,
isSoloingAccount,
isBulkPausing,
isBulkResuming,
}: ProviderEditorProps) {
const [customPresetOpen, setCustomPresetOpen] = useState(false);
const { privacyMode } = usePrivacy();
const { t } = useTranslation();
const { data: modelsData } = useCliproxyModels();
const { data: presetsData } = usePresets(provider);
const createPresetMutation = useCreatePreset();
const deletePresetMutation = useDeletePreset();
// Use baseProvider for model filtering (for variants, this is the parent provider)
const modelFilterProvider = baseProvider || provider;
const isAgyProvider = modelFilterProvider.toLowerCase() === 'agy';
const savedPresets = useMemo(() => {
const presets = presetsData?.presets || [];
if (!isAgyProvider) return presets;
return presets.filter(
(preset) =>
!isDeniedAgyModelId(preset.default) &&
!isDeniedAgyModelId(preset.opus) &&
!isDeniedAgyModelId(preset.sonnet) &&
!isDeniedAgyModelId(preset.haiku)
);
}, [isAgyProvider, presetsData?.presets]);
const providerModels = useMemo(() => {
if (!modelsData?.models) return [];
const ownerMap: Record<string, string[]> = {
gemini: ['google'],
agy: ['antigravity'],
codex: ['openai'],
cursor: ['cursor'],
gitlab: ['gitlab', 'duo'],
codebuddy: ['codebuddy'],
qwen: ['alibaba', 'qwen'],
iflow: ['iflow'],
kilo: ['kilo'],
kiro: ['kiro', 'aws'],
ghcp: ['github', 'copilot'],
kimi: ['kimi', 'moonshot'],
};
const owners = ownerMap[modelFilterProvider.toLowerCase()] || [
modelFilterProvider.toLowerCase(),
];
return modelsData.models.filter((m) => {
if (!owners.some((o) => m.owned_by.toLowerCase().includes(o))) return false;
if (!isAgyProvider) return true;
return !isDeniedAgyModelId(m.id);
});
}, [isAgyProvider, modelsData, modelFilterProvider]);
const providerRoute = (baseProvider || provider).toLowerCase();
const {
data,
isLoading,
refetch,
rawJsonContent,
rawJsonEdits,
isRawJsonValid,
hasChanges,
currentModel,
opusModel,
sonnetModel,
haikuModel,
extendedContextEnabled,
toggleExtendedContext,
handleRawJsonChange,
updateEnvValue,
updateEnvValues,
saveMutation,
conflictDialog,
handleConflictResolve,
missingRequiredFields,
} = useProviderEditor(provider, catalog);
// Defensive normalization: remote/legacy payloads may omit account.provider.
// Fallback to current editor provider to avoid runtime crashes in account UI.
const accounts = useMemo(
() =>
(authStatus.accounts || []).map((account) => ({
...account,
provider: account.provider || baseProvider || provider,
})),
[authStatus.accounts, baseProvider, provider]
);
// Fetch effective API key for presets (uses configured value, not hardcoded)
const { data: authTokens } = useQuery<{ apiKey: { value: string } }>({
queryKey: ['auth-tokens-raw'],
queryFn: async () => {
const response = await fetch('/api/settings/auth/tokens/raw');
if (!response.ok) return { apiKey: { value: 'ccs-internal-managed' } };
return response.json();
},
staleTime: 60000, // Cache for 1 minute
});
const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed';
const handleApplyPreset = (updates: Record<string, string>) => {
if (
isAgyProvider &&
[
updates.ANTHROPIC_MODEL,
updates.ANTHROPIC_DEFAULT_OPUS_MODEL,
updates.ANTHROPIC_DEFAULT_SONNET_MODEL,
updates.ANTHROPIC_DEFAULT_HAIKU_MODEL,
].some((modelId) => typeof modelId === 'string' && isDeniedAgyModelId(modelId))
) {
toast.error(t('providerEditor.agyDenylist'));
return;
}
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
updateEnvValues({
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`,
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
...updates,
});
toast.success(`Applied "${updates.ANTHROPIC_MODEL?.split('/').pop() || 'preset'}" preset`);
};
const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => {
if (
isAgyProvider &&
[values.default, values.opus, values.sonnet, values.haiku].some((modelId) =>
isDeniedAgyModelId(modelId)
)
) {
toast.error(t('providerEditor.agyDenylist'));
return;
}
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
updateEnvValues({
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`,
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
ANTHROPIC_MODEL: values.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: values.haiku,
});
toast.success(`Applied ${presetName ? `"${presetName}"` : 'custom'} preset`);
setCustomPresetOpen(false);
};
const handleCustomPresetSave = (values: ModelMappingValues, presetName?: string) => {
if (!presetName) {
toast.error(i18n.t('commonToast.enterPresetName'));
return;
}
if (
isAgyProvider &&
[values.default, values.opus, values.sonnet, values.haiku].some((modelId) =>
isDeniedAgyModelId(modelId)
)
) {
toast.error(t('providerEditor.agyDenylist'));
return;
}
createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } });
setCustomPresetOpen(false);
};
return (
<div className="flex-1 flex flex-col overflow-hidden">
<ProviderEditorHeader
provider={provider}
displayName={displayName}
logoProvider={logoProvider}
data={data}
isLoading={isLoading}
hasChanges={hasChanges}
isRawJsonValid={isRawJsonValid}
isSaving={saveMutation.isPending}
isRemoteMode={isRemoteMode}
port={port}
onRefetch={refetch}
onSave={() => saveMutation.mutate()}
/>
{topNotice ? <div className="border-b bg-muted/10 px-4 py-3">{topNotice}</div> : null}
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
<span className="ml-3 text-muted-foreground">{t('providerEditor.loadingSettings')}</span>
</div>
) : (
<div className="min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-muted/5">
<Tabs defaultValue="config" className="h-full flex flex-col">
<div className="px-4 pt-4 shrink-0">
<TabsList className="w-full">
<TabsTrigger value="config" className="flex-1">
Model Config
</TabsTrigger>
<TabsTrigger value="info" className="flex-1">
Info & Usage
</TabsTrigger>
</TabsList>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
<TabsContent
value="config"
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
>
<ModelConfigTab
provider={provider}
catalog={catalog}
savedPresets={savedPresets}
currentModel={currentModel}
opusModel={opusModel}
sonnetModel={sonnetModel}
haikuModel={haikuModel}
providerModels={providerModels}
routing={routing}
extendedContextEnabled={extendedContextEnabled}
onExtendedContextToggle={toggleExtendedContext}
onApplyPreset={handleApplyPreset}
onUpdateEnvValue={updateEnvValue}
onOpenCustomPreset={() => setCustomPresetOpen(true)}
onDeletePreset={(name) =>
deletePresetMutation.mutate({ profile: provider, name })
}
isDeletePending={deletePresetMutation.isPending}
accounts={accounts}
onAddAccount={onAddAccount}
onSetDefault={onSetDefault}
onRemoveAccount={onRemoveAccount}
onPauseToggle={onPauseToggle}
onSoloMode={onSoloMode}
onBulkPause={onBulkPause}
onBulkResume={onBulkResume}
isRemovingAccount={isRemovingAccount}
isPausingAccount={isPausingAccount}
isSoloingAccount={isSoloingAccount}
isBulkPausing={isBulkPausing}
isBulkResuming={isBulkResuming}
privacyMode={privacyMode}
isRemoteMode={isRemoteMode}
/>
</TabsContent>
<TabsContent
value="info"
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
>
<ProviderInfoTab
provider={provider}
displayName={displayName}
baseProvider={baseProvider}
defaultTarget={defaultTarget}
data={data}
authStatus={authStatus}
supportsModelConfig={Boolean(catalog)}
/>
</TabsContent>
</div>
</Tabs>
</div>
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden">
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
{t('rawEditorSection.rawConfig')} (JSON)
</span>
</div>
<RawEditorSection
rawJsonContent={rawJsonContent}
isRawJsonValid={isRawJsonValid}
rawJsonEdits={rawJsonEdits}
onRawJsonChange={handleRawJsonChange}
profileEnv={data?.settings?.env}
missingRequiredFields={missingRequiredFields}
/>
</div>
</div>
)}
<ConfirmDialog
open={conflictDialog}
title="File Modified Externally"
description="This settings file was modified by another process. Overwrite with your changes or discard?"
confirmText="Overwrite"
variant="destructive"
onConfirm={() => handleConflictResolve(true)}
onCancel={() => handleConflictResolve(false)}
/>
<CustomPresetDialog
open={customPresetOpen}
onClose={() => setCustomPresetOpen(false)}
currentValues={{
default: currentModel || '',
opus: opusModel || '',
sonnet: sonnetModel || '',
haiku: haikuModel || '',
}}
onApply={handleCustomPresetApply}
onSave={handleCustomPresetSave}
isSaving={createPresetMutation.isPending}
catalog={catalog}
allModels={providerModels}
routing={routing}
/>
</div>
);
}
export type { ProviderEditorProps, ModelMappingValues } from './types';
export { AccountItem } from './account-item';
export { UsageCommand } from './usage-command';
export { CustomPresetDialog } from './custom-preset-dialog';
export { ModelConfigSection } from './model-config-section';
export { RawEditorSection } from './raw-editor-section';
export { AccountsSection } from './accounts-section';
export { ProviderInfoTab } from './provider-info-tab';
export { ProviderEditorHeader } from './provider-editor-header';
export { ModelConfigTab } from './model-config-tab';
export { useProviderEditor } from './use-provider-editor';