From f6c86b79fcbc041e6c2b586f62078739032aab40 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 22 Apr 2026 19:51:20 -0400 Subject: [PATCH] feat(cliproxy): separate core and plus provider sections --- .../backend-ui-default-ports-sync.test.ts | 14 +++++ .../components/cliproxy/cliproxy-dialog.tsx | 54 +++++++++++++--- .../cliproxy/cliproxy-edit-dialog.tsx | 56 ++++++++++++++--- .../provider-editor/provider-info-tab.tsx | 16 +++++ ui/src/lib/provider-config.ts | 63 +++++++++++++++++++ ui/src/pages/cliproxy.tsx | 40 +++++++++--- .../pages/settings/sections/proxy/index.tsx | 37 ++++++++--- .../provider-info-tab.test.tsx | 24 +++++++ ui/tests/unit/pages/cliproxy-page.test.tsx | 59 +++++++++++++---- ui/tests/unit/ui/lib/provider-config.test.ts | 40 ++++++++++++ 10 files changed, 355 insertions(+), 48 deletions(-) diff --git a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts index c8fe0d1d..5670eecf 100644 --- a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts +++ b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts @@ -19,9 +19,12 @@ import { } from '../../../ui/src/lib/default-ports'; import { CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS, + CORE_CLIPROXY_PROVIDERS as UI_CORE_CLIPROXY_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, } 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[] { 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'))); }); + 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', () => { for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) { expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider)); diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index b221b8a6..3256f236 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -19,7 +19,13 @@ import { useTranslation } from 'react-i18next'; import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy'; import { usePrivacy } from '@/contexts/privacy-context'; 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'; const singleProviderSchema = z.object({ @@ -104,6 +110,8 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { }); 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 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" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} {singleForm.formState.errors.provider && ( @@ -208,6 +222,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { {singleForm.formState.errors.provider.message} )} + {selectedProviderSection && ( +

+ {selectedProviderSection.hint} + {isPlusExtraProvider(selectedProvider) + ? ' Requires the optional Plus backend while that track remains community-maintained.' + : ''} +

+ )} {selectedProvider && providerAccounts.length > 0 && ( @@ -297,12 +319,26 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { {...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" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {compositeTiers?.[tier]?.provider && ( +

+ {getProviderSection(compositeTiers[tier].provider)?.hint} + {isPlusExtraProvider(compositeTiers[tier].provider) + ? ' Requires the optional Plus backend while that track remains community-maintained.' + : ''} +

+ )}
diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx index 9274e975..16825e7f 100644 --- a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -3,7 +3,7 @@ * 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 * as z from 'zod'; import { useEffect } from 'react'; @@ -15,7 +15,13 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; 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 { isDeniedAgyModelId } from '@/lib/utils'; @@ -137,6 +143,8 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit const compositeForm = useForm({ 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 useEffect(() => { @@ -318,12 +326,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit {...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" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {compositeTiers?.[tier]?.provider && ( +

+ {getProviderSection(compositeTiers[tier].provider)?.hint} + {isPlusExtraProvider(compositeTiers[tier].provider) + ? ' Requires the optional Plus backend while that track remains community-maintained.' + : ''} +

+ )}
@@ -399,12 +421,26 @@ export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEdit {...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" > - {providerOptions.map((opt) => ( - + {CLIPROXY_PROVIDER_SECTIONS.map((section) => ( + + {providerOptions + .filter((opt) => section.providers.includes(opt.value)) + .map((opt) => ( + + ))} + ))} + {selectedProvider && ( +

+ {getProviderSection(selectedProvider)?.hint} + {isPlusExtraProvider(selectedProvider) + ? ' Requires the optional Plus backend while that track remains community-maintained.' + : ''} +

+ )}
diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx index 2ea26cdf..22edadaa 100644 --- a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -10,6 +10,7 @@ import { Info, Shield } from 'lucide-react'; import { UsageCommand } from './usage-command'; import type { SettingsResponse } from './types'; import type { AuthStatus, CliTarget } from '@/lib/api-client'; +import { getProviderSection, isPlusExtraProvider } from '@/lib/provider-config'; import { useTranslation } from 'react-i18next'; interface ProviderInfoTabProps { @@ -33,6 +34,7 @@ export function ProviderInfoTab({ const resolvedTarget = defaultTarget || 'claude'; const isDroidTarget = resolvedTarget === 'droid'; const isCodexProvider = provider === 'codex'; + const providerSection = getProviderSection(provider); const managementPrefix = resolvedTarget === 'claude' ? `ccs ${provider}` : `ccs ${provider} --target claude`; const changeModelCommand = `${managementPrefix} --config`; @@ -101,6 +103,20 @@ export function ProviderInfoTab({ {resolvedTarget}
+ {providerSection && ( +
+ Track +
+ {providerSection.label} +

+ {providerSection.hint} + {isPlusExtraProvider(provider) + ? ' Requires the optional Plus backend while that track remains community-maintained.' + : ''} +

+
+
+ )} diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 3500e4a4..7f824ce0 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -10,6 +10,7 @@ import { getProvidersByOAuthFlow, } from '../../../src/cliproxy/provider-capabilities'; import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers'; +import { PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types'; import i18n from './i18n'; // 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 type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number]; 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(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. */ 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 }); } +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( + items: readonly T[], + getProvider: (item: T) => unknown +): Array { + 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 */ export function getProviderDescription(provider: unknown): string { const normalized = normalizeProviderInput(provider); diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 6aba8344..f48374e2 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -33,7 +33,11 @@ import { } from '@/hooks/use-cliproxy'; import type { AuthStatus, Variant } from '@/lib/api-client'; 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 { useTranslation } from 'react-i18next'; @@ -261,6 +265,10 @@ export function CliproxyPage() { }); const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]); + const providerSections = useMemo( + () => groupProvidersBySection(providers, (status) => status.provider), + [providers] + ); const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); @@ -393,14 +401,28 @@ export function CliproxyPage() { ))} ) : ( -
- {providers.map((status) => ( - handleSelectProvider(status.provider)} - /> +
+ {providerSections.map((section) => ( +
+
+
+ {section.label} +
+

+ {section.hint} +

+
+
+ {section.items.map((status) => ( + handleSelectProvider(status.provider)} + /> + ))} +
+
))}
)} diff --git a/ui/src/pages/settings/sections/proxy/index.tsx b/ui/src/pages/settings/sections/proxy/index.tsx index 76ec80e1..f5b237ae 100644 --- a/ui/src/pages/settings/sections/proxy/index.tsx +++ b/ui/src/pages/settings/sections/proxy/index.tsx @@ -29,15 +29,18 @@ import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; import { api } from '@/lib/api-client'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; 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 { useTranslation } from 'react-i18next'; /** LocalStorage key for debug mode preference */ 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 { return value.trim().replace(/\s+/g, ' ').toUpperCase(); } @@ -196,6 +199,8 @@ export default function ProxySection() { const updateBackendMutation = useUpdateBackend(); const { data: proxyStatus } = useProxyStatus(); 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 const fetchBackend = useCallback(async () => { @@ -211,7 +216,7 @@ export default function ProxySection() { const checkPlusOnlyVariants = useCallback(async () => { try { 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); } catch (err) { console.error('[Proxy] Failed to check variants:', err); @@ -491,7 +496,13 @@ export default function ProxySection() {
{t('settingsProxy.backendPlusApi')}
-

{t('settingsProxy.plusDesc')}

+

+ Optional track for extra providers. Still supported, but currently + community-maintained instead of upstream-maintained. +

+

+ {plusProviderNames} +

{/* Original Backend Card */} @@ -510,15 +521,20 @@ export default function ProxySection() { {t('settingsProxy.default')}
-

{t('settingsProxy.originalDesc')}

+

+ Default, always-available backend for the core provider track. +

+

+ {coreProviderNames} +

{backend === 'plus' && ( - CLIProxyAPIPlus upstream is currently unavailable. Local CLIProxy will use the - original backend until issue #1062 is resolved. + The Plus provider track is not deprecated, but local CLIProxy still falls back to + the original backend while the maintained fork path is being brought back. )} @@ -526,7 +542,10 @@ export default function ProxySection() { {backend === 'original' && hasKiroGhcpVariants && ( - {t('settingsProxy.variantsIncompatible')} + + Existing plus-extra variants ({plusProviderNames}) will not run on the original + backend. Keep them visible for reference, but switch to Plus before using them. + )} diff --git a/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx index 9d71278d..0606a688 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/provider-info-tab.test.tsx @@ -51,4 +51,28 @@ describe('ProviderInfoTab', () => { expect(screen.queryByText('Change model')).not.toBeInTheDocument(); expect(screen.getByText('ccs custom-provider --auth --add')).toBeInTheDocument(); }); + + it('shows the plus-extra track note for community-maintained providers', () => { + render( + + ); + + 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(); + }); }); diff --git a/ui/tests/unit/pages/cliproxy-page.test.tsx b/ui/tests/unit/pages/cliproxy-page.test.tsx index bd651bfb..92b57035 100644 --- a/ui/tests/unit/pages/cliproxy-page.test.tsx +++ b/ui/tests/unit/pages/cliproxy-page.test.tsx @@ -2,6 +2,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render, screen, userEvent } from '@tests/setup/test-utils'; 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 | { catalogs: Record< @@ -23,17 +40,7 @@ vi.mock('@/hooks/use-cliproxy', () => ({ isFetching: false, }), useCliproxyAuth: () => ({ - data: { - authStatus: [ - { - provider: 'gemini', - displayName: 'Gemini', - authenticated: true, - accounts: [{ id: 'acct-1', provider: 'gemini' }], - }, - ], - source: 'local', - }, + data: hookState.authData, isLoading: false, }), useCliproxyCatalog: () => ({ @@ -85,9 +92,39 @@ import { CliproxyPage } from '@/pages/cliproxy'; describe('CliproxyPage add-account catalog gating', () => { 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; }); + it('separates core providers from plus extras in the sidebar', () => { + render(); + + 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 () => { render(); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index b33f24b1..89905299 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -1,14 +1,20 @@ import { describe, expect, it } from 'vitest'; import { + CLIPROXY_PROVIDER_SECTIONS, + CORE_CLIPROXY_PROVIDERS, formatRequestedUpstreamModelRules, getProviderDescription, getProviderDisplayName, getProviderFallbackVisual, getProviderLogoAsset, + getProviderSection, getRequestedUpstreamModelRuleErrors, getRequestedModelId, + groupProvidersBySection, + isPlusExtraProvider, parseRequestedUpstreamModelRules, + PLUS_EXTRA_CLIPROXY_PROVIDERS, PROVIDER_COLORS, } from '@/lib/provider-config'; @@ -49,6 +55,40 @@ describe('provider model mapping helpers', () => { }); 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([ ['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'], ['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'],