mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 04:17:54 +00:00
feat(cliproxy): separate core and plus provider sections
This commit is contained in:
@@ -19,9 +19,12 @@ import {
|
|||||||
} from '../../../ui/src/lib/default-ports';
|
} from '../../../ui/src/lib/default-ports';
|
||||||
import {
|
import {
|
||||||
CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS,
|
CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS,
|
||||||
|
CORE_CLIPROXY_PROVIDERS as UI_CORE_CLIPROXY_PROVIDERS,
|
||||||
DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS,
|
DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS,
|
||||||
|
PLUS_EXTRA_CLIPROXY_PROVIDERS as UI_PLUS_EXTRA_CLIPROXY_PROVIDERS,
|
||||||
PROVIDER_METADATA as UI_PROVIDER_METADATA,
|
PROVIDER_METADATA as UI_PROVIDER_METADATA,
|
||||||
} from '../../../ui/src/lib/provider-config';
|
} from '../../../ui/src/lib/provider-config';
|
||||||
|
import { PLUS_ONLY_PROVIDERS as BACKEND_PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types';
|
||||||
|
|
||||||
function sorted(values: readonly string[]): string[] {
|
function sorted(values: readonly string[]): string[] {
|
||||||
return [...values].sort((a, b) => a.localeCompare(b));
|
return [...values].sort((a, b) => a.localeCompare(b));
|
||||||
@@ -44,6 +47,17 @@ describe('Default Port Sync', () => {
|
|||||||
expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code')));
|
expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code')));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('plus-extra providers are synced between backend and UI', () => {
|
||||||
|
expect(sorted(UI_PLUS_EXTRA_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_PLUS_ONLY_PROVIDERS));
|
||||||
|
expect(sorted(UI_CORE_CLIPROXY_PROVIDERS)).toEqual(
|
||||||
|
sorted(
|
||||||
|
BACKEND_CLIPROXY_PROVIDER_IDS.filter(
|
||||||
|
(provider) => !BACKEND_PLUS_ONLY_PROVIDERS.includes(provider)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('Provider display names are synced between backend and UI', () => {
|
test('Provider display names are synced between backend and UI', () => {
|
||||||
for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) {
|
for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) {
|
||||||
expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider));
|
expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider));
|
||||||
|
|||||||
@@ -19,7 +19,13 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||||
import { usePrivacy } from '@/contexts/privacy-context';
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
import { formatAccountDisplayName } from '@/lib/account-identity';
|
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
import {
|
||||||
|
CLIPROXY_PROVIDERS,
|
||||||
|
CLIPROXY_PROVIDER_SECTIONS,
|
||||||
|
getProviderDisplayName,
|
||||||
|
getProviderSection,
|
||||||
|
isPlusExtraProvider,
|
||||||
|
} from '@/lib/provider-config';
|
||||||
import { isDeniedAgyModelId } from '@/lib/utils';
|
import { isDeniedAgyModelId } from '@/lib/utils';
|
||||||
|
|
||||||
const singleProviderSchema = z.object({
|
const singleProviderSchema = z.object({
|
||||||
@@ -104,6 +110,8 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' });
|
const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' });
|
||||||
|
const compositeTiers = useWatch({ control: compositeForm.control, name: 'tiers' });
|
||||||
|
const selectedProviderSection = getProviderSection(selectedProvider);
|
||||||
const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider);
|
const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider);
|
||||||
const providerAccounts = providerAuth?.accounts || [];
|
const providerAccounts = providerAuth?.accounts || [];
|
||||||
|
|
||||||
@@ -197,10 +205,16 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="">{t('cliproxyDialog.selectProvider')}</option>
|
<option value="">{t('cliproxyDialog.selectProvider')}</option>
|
||||||
{providerOptions.map((opt) => (
|
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<optgroup key={section.id} label={section.label}>
|
||||||
{opt.label}
|
{providerOptions
|
||||||
</option>
|
.filter((opt) => section.providers.includes(opt.value))
|
||||||
|
.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{singleForm.formState.errors.provider && (
|
{singleForm.formState.errors.provider && (
|
||||||
@@ -208,6 +222,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
{singleForm.formState.errors.provider.message}
|
{singleForm.formState.errors.provider.message}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{selectedProviderSection && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{selectedProviderSection.hint}
|
||||||
|
{isPlusExtraProvider(selectedProvider)
|
||||||
|
? ' Requires the optional Plus backend while that track remains community-maintained.'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedProvider && providerAccounts.length > 0 && (
|
{selectedProvider && providerAccounts.length > 0 && (
|
||||||
@@ -297,12 +319,26 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
{...compositeForm.register(`tiers.${tier}.provider`)}
|
{...compositeForm.register(`tiers.${tier}.provider`)}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
{providerOptions.map((opt) => (
|
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<optgroup key={section.id} label={section.label}>
|
||||||
{opt.label}
|
{providerOptions
|
||||||
</option>
|
.filter((opt) => section.providers.includes(opt.value))
|
||||||
|
.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{compositeTiers?.[tier]?.provider && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{getProviderSection(compositeTiers[tier].provider)?.hint}
|
||||||
|
{isPlusExtraProvider(compositeTiers[tier].provider)
|
||||||
|
? ' Requires the optional Plus backend while that track remains community-maintained.'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor={`${tier}-model`}>{t('cliproxyDialog.model')}</Label>
|
<Label htmlFor={`${tier}-model`}>{t('cliproxyDialog.model')}</Label>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Phase 05: Dashboard UI full CRUD for composite variants
|
* Phase 05: Dashboard UI full CRUD for composite variants
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
@@ -15,7 +15,13 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useUpdateVariant } from '@/hooks/use-cliproxy';
|
import { useUpdateVariant } from '@/hooks/use-cliproxy';
|
||||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
import {
|
||||||
|
CLIPROXY_PROVIDERS,
|
||||||
|
CLIPROXY_PROVIDER_SECTIONS,
|
||||||
|
getProviderDisplayName,
|
||||||
|
getProviderSection,
|
||||||
|
isPlusExtraProvider,
|
||||||
|
} from '@/lib/provider-config';
|
||||||
import type { UpdateVariant, Variant } from '@/lib/api-client';
|
import type { UpdateVariant, Variant } from '@/lib/api-client';
|
||||||
import { isDeniedAgyModelId } from '@/lib/utils';
|
import { isDeniedAgyModelId } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -137,6 +143,8 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
|
|||||||
const compositeForm = useForm<CompositeFormData>({
|
const compositeForm = useForm<CompositeFormData>({
|
||||||
resolver: zodResolver(compositeSchema),
|
resolver: zodResolver(compositeSchema),
|
||||||
});
|
});
|
||||||
|
const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' });
|
||||||
|
const compositeTiers = useWatch({ control: compositeForm.control, name: 'tiers' });
|
||||||
|
|
||||||
// Pre-populate form when variant changes
|
// Pre-populate form when variant changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -318,12 +326,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
|
|||||||
{...compositeForm.register(`tiers.${tier}.provider`)}
|
{...compositeForm.register(`tiers.${tier}.provider`)}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
{providerOptions.map((opt) => (
|
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<optgroup key={section.id} label={section.label}>
|
||||||
{opt.label}
|
{providerOptions
|
||||||
</option>
|
.filter((opt) => section.providers.includes(opt.value))
|
||||||
|
.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{compositeTiers?.[tier]?.provider && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{getProviderSection(compositeTiers[tier].provider)?.hint}
|
||||||
|
{isPlusExtraProvider(compositeTiers[tier].provider)
|
||||||
|
? ' Requires the optional Plus backend while that track remains community-maintained.'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor={`edit-${tier}-model`}>{t('cliproxyDialog.model')}</Label>
|
<Label htmlFor={`edit-${tier}-model`}>{t('cliproxyDialog.model')}</Label>
|
||||||
@@ -399,12 +421,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit
|
|||||||
{...singleForm.register('provider')}
|
{...singleForm.register('provider')}
|
||||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
{providerOptions.map((opt) => (
|
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<optgroup key={section.id} label={section.label}>
|
||||||
{opt.label}
|
{providerOptions
|
||||||
</option>
|
.filter((opt) => section.providers.includes(opt.value))
|
||||||
|
.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{selectedProvider && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{getProviderSection(selectedProvider)?.hint}
|
||||||
|
{isPlusExtraProvider(selectedProvider)
|
||||||
|
? ' Requires the optional Plus backend while that track remains community-maintained.'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Info, Shield } from 'lucide-react';
|
|||||||
import { UsageCommand } from './usage-command';
|
import { UsageCommand } from './usage-command';
|
||||||
import type { SettingsResponse } from './types';
|
import type { SettingsResponse } from './types';
|
||||||
import type { AuthStatus, CliTarget } from '@/lib/api-client';
|
import type { AuthStatus, CliTarget } from '@/lib/api-client';
|
||||||
|
import { getProviderSection, isPlusExtraProvider } from '@/lib/provider-config';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface ProviderInfoTabProps {
|
interface ProviderInfoTabProps {
|
||||||
@@ -33,6 +34,7 @@ export function ProviderInfoTab({
|
|||||||
const resolvedTarget = defaultTarget || 'claude';
|
const resolvedTarget = defaultTarget || 'claude';
|
||||||
const isDroidTarget = resolvedTarget === 'droid';
|
const isDroidTarget = resolvedTarget === 'droid';
|
||||||
const isCodexProvider = provider === 'codex';
|
const isCodexProvider = provider === 'codex';
|
||||||
|
const providerSection = getProviderSection(provider);
|
||||||
const managementPrefix =
|
const managementPrefix =
|
||||||
resolvedTarget === 'claude' ? `ccs ${provider}` : `ccs ${provider} --target claude`;
|
resolvedTarget === 'claude' ? `ccs ${provider}` : `ccs ${provider} --target claude`;
|
||||||
const changeModelCommand = `${managementPrefix} --config`;
|
const changeModelCommand = `${managementPrefix} --config`;
|
||||||
@@ -101,6 +103,20 @@ export function ProviderInfoTab({
|
|||||||
</span>
|
</span>
|
||||||
<span className="font-mono">{resolvedTarget}</span>
|
<span className="font-mono">{resolvedTarget}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{providerSection && (
|
||||||
|
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
|
||||||
|
<span className="font-medium text-muted-foreground">Track</span>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="font-mono">{providerSection.label}</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{providerSection.hint}
|
||||||
|
{isPlusExtraProvider(provider)
|
||||||
|
? ' Requires the optional Plus backend while that track remains community-maintained.'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getProvidersByOAuthFlow,
|
getProvidersByOAuthFlow,
|
||||||
} from '../../../src/cliproxy/provider-capabilities';
|
} from '../../../src/cliproxy/provider-capabilities';
|
||||||
import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers';
|
import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers';
|
||||||
|
import { PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types';
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
|
|
||||||
// Monorepo contract: UI consumes provider capability constants directly from backend
|
// Monorepo contract: UI consumes provider capability constants directly from backend
|
||||||
@@ -19,6 +20,39 @@ import i18n from './i18n';
|
|||||||
export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS;
|
export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS;
|
||||||
export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number];
|
export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number];
|
||||||
export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex';
|
export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex';
|
||||||
|
export type CLIProxyProviderSectionId = 'core' | 'plus-extra';
|
||||||
|
|
||||||
|
export interface CLIProxyProviderSection {
|
||||||
|
id: CLIProxyProviderSectionId;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
providers: readonly CLIProxyProvider[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLUS_ONLY_PROVIDER_SET = new Set<CLIProxyProvider>(PLUS_ONLY_PROVIDERS);
|
||||||
|
|
||||||
|
export const CORE_CLIPROXY_PROVIDERS: readonly CLIProxyProvider[] = Object.freeze(
|
||||||
|
CLIPROXY_PROVIDERS.filter((provider) => !PLUS_ONLY_PROVIDER_SET.has(provider))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PLUS_EXTRA_CLIPROXY_PROVIDERS: readonly CLIProxyProvider[] = Object.freeze(
|
||||||
|
CLIPROXY_PROVIDERS.filter((provider) => PLUS_ONLY_PROVIDER_SET.has(provider))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CLIPROXY_PROVIDER_SECTIONS: readonly CLIProxyProviderSection[] = Object.freeze([
|
||||||
|
{
|
||||||
|
id: 'core',
|
||||||
|
label: 'Core / original backend',
|
||||||
|
hint: 'Default, always-available provider track',
|
||||||
|
providers: CORE_CLIPROXY_PROVIDERS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'plus-extra',
|
||||||
|
label: 'Plus extras / community-maintained',
|
||||||
|
hint: 'Still supported, but separated from the default backend for now',
|
||||||
|
providers: PLUS_EXTRA_CLIPROXY_PROVIDERS,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
/** Check if a string is a backend-supported CLIProxy provider. */
|
/** Check if a string is a backend-supported CLIProxy provider. */
|
||||||
export function isValidProvider(provider: string): provider is CLIProxyProvider {
|
export function isValidProvider(provider: string): provider is CLIProxyProvider {
|
||||||
@@ -259,6 +293,35 @@ export function getProviderDisplayName(provider: unknown): string {
|
|||||||
return PROVIDER_NAMES[normalized] || i18n.t('toasts.providerUnknown', { provider: normalized });
|
return PROVIDER_NAMES[normalized] || i18n.t('toasts.providerUnknown', { provider: normalized });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPlusExtraProvider(provider: unknown): boolean {
|
||||||
|
const normalized = normalizeProviderInput(provider);
|
||||||
|
return isValidProvider(normalized) && PLUS_ONLY_PROVIDER_SET.has(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderSection(provider: unknown): CLIProxyProviderSection | null {
|
||||||
|
const normalized = normalizeProviderInput(provider);
|
||||||
|
if (!isValidProvider(normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
CLIPROXY_PROVIDER_SECTIONS.find((section) => section.providers.includes(normalized)) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupProvidersBySection<T>(
|
||||||
|
items: readonly T[],
|
||||||
|
getProvider: (item: T) => unknown
|
||||||
|
): Array<CLIProxyProviderSection & { items: T[] }> {
|
||||||
|
return CLIPROXY_PROVIDER_SECTIONS.map((section) => ({
|
||||||
|
...section,
|
||||||
|
items: items.filter((item) => {
|
||||||
|
const normalized = normalizeProviderInput(getProvider(item));
|
||||||
|
return isValidProvider(normalized) && section.providers.includes(normalized);
|
||||||
|
}),
|
||||||
|
})).filter((section) => section.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
/** Map provider to user-facing short description */
|
/** Map provider to user-facing short description */
|
||||||
export function getProviderDescription(provider: unknown): string {
|
export function getProviderDescription(provider: unknown): string {
|
||||||
const normalized = normalizeProviderInput(provider);
|
const normalized = normalizeProviderInput(provider);
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ import {
|
|||||||
} from '@/hooks/use-cliproxy';
|
} from '@/hooks/use-cliproxy';
|
||||||
import type { AuthStatus, Variant } from '@/lib/api-client';
|
import type { AuthStatus, Variant } from '@/lib/api-client';
|
||||||
import { buildUiCatalogs } from '@/lib/model-catalogs';
|
import { buildUiCatalogs } from '@/lib/model-catalogs';
|
||||||
import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config';
|
import {
|
||||||
|
getProviderDisplayName,
|
||||||
|
groupProvidersBySection,
|
||||||
|
isValidProvider,
|
||||||
|
} from '@/lib/provider-config';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -261,6 +265,10 @@ export function CliproxyPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
|
const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]);
|
||||||
|
const providerSections = useMemo(
|
||||||
|
() => groupProvidersBySection(providers, (status) => status.provider),
|
||||||
|
[providers]
|
||||||
|
);
|
||||||
const isRemoteMode = authData?.source === 'remote';
|
const isRemoteMode = authData?.source === 'remote';
|
||||||
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]);
|
||||||
const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]);
|
const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]);
|
||||||
@@ -393,14 +401,28 @@ export function CliproxyPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-4">
|
||||||
{providers.map((status) => (
|
{providerSections.map((section) => (
|
||||||
<ProviderSidebarItem
|
<div key={section.id} className="space-y-1">
|
||||||
key={status.provider}
|
<div className="px-3">
|
||||||
status={status}
|
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
isSelected={effectiveProvider === status.provider}
|
{section.label}
|
||||||
onSelect={() => handleSelectProvider(status.provider)}
|
</div>
|
||||||
/>
|
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
|
||||||
|
{section.hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{section.items.map((status) => (
|
||||||
|
<ProviderSidebarItem
|
||||||
|
key={status.provider}
|
||||||
|
status={status}
|
||||||
|
isSelected={effectiveProvider === status.provider}
|
||||||
|
onSelect={() => handleSelectProvider(status.provider)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -29,15 +29,18 @@ import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
|
|||||||
import { api } from '@/lib/api-client';
|
import { api } from '@/lib/api-client';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||||
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
|
import { RISK_ACK_PHRASE } from '@/components/account/antigravity-responsibility-constants';
|
||||||
|
import {
|
||||||
|
CORE_CLIPROXY_PROVIDERS,
|
||||||
|
PLUS_EXTRA_CLIPROXY_PROVIDERS,
|
||||||
|
getProviderDisplayName,
|
||||||
|
isPlusExtraProvider,
|
||||||
|
} from '@/lib/provider-config';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
/** LocalStorage key for debug mode preference */
|
/** LocalStorage key for debug mode preference */
|
||||||
const DEBUG_MODE_KEY = 'ccs_debug_mode';
|
const DEBUG_MODE_KEY = 'ccs_debug_mode';
|
||||||
|
|
||||||
/** Providers only available on CLIProxyAPIPlus */
|
|
||||||
const PLUS_ONLY_PROVIDERS = ['kiro', 'ghcp', 'cursor', 'gitlab', 'codebuddy', 'kilo'];
|
|
||||||
|
|
||||||
function normalizeRiskAckPhrase(value: string): string {
|
function normalizeRiskAckPhrase(value: string): string {
|
||||||
return value.trim().replace(/\s+/g, ' ').toUpperCase();
|
return value.trim().replace(/\s+/g, ' ').toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -196,6 +199,8 @@ export default function ProxySection() {
|
|||||||
const updateBackendMutation = useUpdateBackend();
|
const updateBackendMutation = useUpdateBackend();
|
||||||
const { data: proxyStatus } = useProxyStatus();
|
const { data: proxyStatus } = useProxyStatus();
|
||||||
const isProxyRunning = proxyStatus?.running ?? false;
|
const isProxyRunning = proxyStatus?.running ?? false;
|
||||||
|
const coreProviderNames = CORE_CLIPROXY_PROVIDERS.map(getProviderDisplayName).join(', ');
|
||||||
|
const plusProviderNames = PLUS_EXTRA_CLIPROXY_PROVIDERS.map(getProviderDisplayName).join(', ');
|
||||||
|
|
||||||
// Fetch backend setting
|
// Fetch backend setting
|
||||||
const fetchBackend = useCallback(async () => {
|
const fetchBackend = useCallback(async () => {
|
||||||
@@ -211,7 +216,7 @@ export default function ProxySection() {
|
|||||||
const checkPlusOnlyVariants = useCallback(async () => {
|
const checkPlusOnlyVariants = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await api.cliproxy.list();
|
const result = await api.cliproxy.list();
|
||||||
const hasIncompatible = result.variants.some((v) => PLUS_ONLY_PROVIDERS.includes(v.provider));
|
const hasIncompatible = result.variants.some((v) => isPlusExtraProvider(v.provider));
|
||||||
setHasKiroGhcpVariants(hasIncompatible);
|
setHasKiroGhcpVariants(hasIncompatible);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Proxy] Failed to check variants:', err);
|
console.error('[Proxy] Failed to check variants:', err);
|
||||||
@@ -491,7 +496,13 @@ export default function ProxySection() {
|
|||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<span className="font-medium">{t('settingsProxy.backendPlusApi')}</span>
|
<span className="font-medium">{t('settingsProxy.backendPlusApi')}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t('settingsProxy.plusDesc')}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Optional track for extra providers. Still supported, but currently
|
||||||
|
community-maintained instead of upstream-maintained.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||||
|
{plusProviderNames}
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Original Backend Card */}
|
{/* Original Backend Card */}
|
||||||
@@ -510,15 +521,20 @@ export default function ProxySection() {
|
|||||||
{t('settingsProxy.default')}
|
{t('settingsProxy.default')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t('settingsProxy.originalDesc')}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Default, always-available backend for the core provider track.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
|
||||||
|
{coreProviderNames}
|
||||||
|
</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{backend === 'plus' && (
|
{backend === 'plus' && (
|
||||||
<Alert className="py-2 border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20 [&>svg]:top-2.5">
|
<Alert className="py-2 border-amber-200 bg-amber-50 dark:border-amber-900/50 dark:bg-amber-900/20 [&>svg]:top-2.5">
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
<AlertTriangle className="h-4 w-4 text-amber-600" />
|
||||||
<AlertDescription className="text-amber-700 dark:text-amber-400">
|
<AlertDescription className="text-amber-700 dark:text-amber-400">
|
||||||
CLIProxyAPIPlus upstream is currently unavailable. Local CLIProxy will use the
|
The Plus provider track is not deprecated, but local CLIProxy still falls back to
|
||||||
original backend until issue #1062 is resolved.
|
the original backend while the maintained fork path is being brought back.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -526,7 +542,10 @@ export default function ProxySection() {
|
|||||||
{backend === 'original' && hasKiroGhcpVariants && (
|
{backend === 'original' && hasKiroGhcpVariants && (
|
||||||
<Alert variant="destructive" className="py-2">
|
<Alert variant="destructive" className="py-2">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertDescription>{t('settingsProxy.variantsIncompatible')}</AlertDescription>
|
<AlertDescription>
|
||||||
|
Existing plus-extra variants ({plusProviderNames}) will not run on the original
|
||||||
|
backend. Keep them visible for reference, but switch to Plus before using them.
|
||||||
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,4 +51,28 @@ describe('ProviderInfoTab', () => {
|
|||||||
expect(screen.queryByText('Change model')).not.toBeInTheDocument();
|
expect(screen.queryByText('Change model')).not.toBeInTheDocument();
|
||||||
expect(screen.getByText('ccs custom-provider --auth --add')).toBeInTheDocument();
|
expect(screen.getByText('ccs custom-provider --auth --add')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the plus-extra track note for community-maintained providers', () => {
|
||||||
|
render(
|
||||||
|
<ProviderInfoTab
|
||||||
|
provider="cursor"
|
||||||
|
displayName="Cursor"
|
||||||
|
defaultTarget="claude"
|
||||||
|
authStatus={{
|
||||||
|
...authenticatedStatus,
|
||||||
|
provider: 'cursor',
|
||||||
|
displayName: 'Cursor',
|
||||||
|
}}
|
||||||
|
supportsModelConfig
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Track')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
/Requires the optional Plus backend while that track remains community-maintained\./
|
||||||
|
)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||||
|
|
||||||
const hookState = vi.hoisted(() => ({
|
const hookState = vi.hoisted(() => ({
|
||||||
|
authData: {
|
||||||
|
authStatus: [
|
||||||
|
{
|
||||||
|
provider: 'gemini',
|
||||||
|
displayName: 'Gemini',
|
||||||
|
authenticated: true,
|
||||||
|
accounts: [{ id: 'acct-1', provider: 'gemini' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'ghcp',
|
||||||
|
displayName: 'GitHub Copilot (OAuth)',
|
||||||
|
authenticated: false,
|
||||||
|
accounts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: 'local' as const,
|
||||||
|
},
|
||||||
catalogData: undefined as
|
catalogData: undefined as
|
||||||
| {
|
| {
|
||||||
catalogs: Record<
|
catalogs: Record<
|
||||||
@@ -23,17 +40,7 @@ vi.mock('@/hooks/use-cliproxy', () => ({
|
|||||||
isFetching: false,
|
isFetching: false,
|
||||||
}),
|
}),
|
||||||
useCliproxyAuth: () => ({
|
useCliproxyAuth: () => ({
|
||||||
data: {
|
data: hookState.authData,
|
||||||
authStatus: [
|
|
||||||
{
|
|
||||||
provider: 'gemini',
|
|
||||||
displayName: 'Gemini',
|
|
||||||
authenticated: true,
|
|
||||||
accounts: [{ id: 'acct-1', provider: 'gemini' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
source: 'local',
|
|
||||||
},
|
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}),
|
}),
|
||||||
useCliproxyCatalog: () => ({
|
useCliproxyCatalog: () => ({
|
||||||
@@ -85,9 +92,39 @@ import { CliproxyPage } from '@/pages/cliproxy';
|
|||||||
|
|
||||||
describe('CliproxyPage add-account catalog gating', () => {
|
describe('CliproxyPage add-account catalog gating', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
hookState.authData = {
|
||||||
|
authStatus: [
|
||||||
|
{
|
||||||
|
provider: 'gemini',
|
||||||
|
displayName: 'Gemini',
|
||||||
|
authenticated: true,
|
||||||
|
accounts: [{ id: 'acct-1', provider: 'gemini' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provider: 'ghcp',
|
||||||
|
displayName: 'GitHub Copilot (OAuth)',
|
||||||
|
authenticated: false,
|
||||||
|
accounts: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
source: 'local',
|
||||||
|
};
|
||||||
hookState.catalogData = undefined;
|
hookState.catalogData = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('separates core providers from plus extras in the sidebar', () => {
|
||||||
|
render(<CliproxyPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Core / original backend')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Default, always-available provider track')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText('Still supported, but separated from the default backend for now')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Gemini')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('GitHub Copilot (OAuth)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not pass a static fallback catalog before the catalog query resolves', async () => {
|
it('does not pass a static fallback catalog before the catalog query resolves', async () => {
|
||||||
render(<CliproxyPage />);
|
render(<CliproxyPage />);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CLIPROXY_PROVIDER_SECTIONS,
|
||||||
|
CORE_CLIPROXY_PROVIDERS,
|
||||||
formatRequestedUpstreamModelRules,
|
formatRequestedUpstreamModelRules,
|
||||||
getProviderDescription,
|
getProviderDescription,
|
||||||
getProviderDisplayName,
|
getProviderDisplayName,
|
||||||
getProviderFallbackVisual,
|
getProviderFallbackVisual,
|
||||||
getProviderLogoAsset,
|
getProviderLogoAsset,
|
||||||
|
getProviderSection,
|
||||||
getRequestedUpstreamModelRuleErrors,
|
getRequestedUpstreamModelRuleErrors,
|
||||||
getRequestedModelId,
|
getRequestedModelId,
|
||||||
|
groupProvidersBySection,
|
||||||
|
isPlusExtraProvider,
|
||||||
parseRequestedUpstreamModelRules,
|
parseRequestedUpstreamModelRules,
|
||||||
|
PLUS_EXTRA_CLIPROXY_PROVIDERS,
|
||||||
PROVIDER_COLORS,
|
PROVIDER_COLORS,
|
||||||
} from '@/lib/provider-config';
|
} from '@/lib/provider-config';
|
||||||
|
|
||||||
@@ -49,6 +55,40 @@ describe('provider model mapping helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('provider presentation metadata', () => {
|
describe('provider presentation metadata', () => {
|
||||||
|
it('splits providers into core and plus-extra sections', () => {
|
||||||
|
expect(CLIPROXY_PROVIDER_SECTIONS.map((section) => section.id)).toEqual(['core', 'plus-extra']);
|
||||||
|
expect(CORE_CLIPROXY_PROVIDERS).toContain('gemini');
|
||||||
|
expect(CORE_CLIPROXY_PROVIDERS).toContain('kimi');
|
||||||
|
expect(PLUS_EXTRA_CLIPROXY_PROVIDERS).toEqual([
|
||||||
|
'kiro',
|
||||||
|
'ghcp',
|
||||||
|
'cursor',
|
||||||
|
'gitlab',
|
||||||
|
'codebuddy',
|
||||||
|
'kilo',
|
||||||
|
]);
|
||||||
|
expect(getProviderSection('gitlab')?.id).toBe('plus-extra');
|
||||||
|
expect(getProviderSection('gemini')?.id).toBe('core');
|
||||||
|
expect(isPlusExtraProvider('cursor')).toBe(true);
|
||||||
|
expect(isPlusExtraProvider('gemini')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups provider-backed data by shared section metadata', () => {
|
||||||
|
const grouped = groupProvidersBySection(
|
||||||
|
[
|
||||||
|
{ provider: 'cursor', value: 'plus' },
|
||||||
|
{ provider: 'gemini', value: 'core' },
|
||||||
|
],
|
||||||
|
(entry) => entry.provider
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(grouped).toHaveLength(2);
|
||||||
|
expect(grouped[0]?.id).toBe('core');
|
||||||
|
expect(grouped[0]?.items.map((entry) => entry.provider)).toEqual(['gemini']);
|
||||||
|
expect(grouped[1]?.id).toBe('plus-extra');
|
||||||
|
expect(grouped[1]?.items.map((entry) => entry.provider)).toEqual(['cursor']);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'],
|
['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'],
|
||||||
['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'],
|
['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'],
|
||||||
|
|||||||
Reference in New Issue
Block a user