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..cb8ff68b 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) => (
+
))}
{singleForm.formState.errors.provider && (
@@ -208,6 +222,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
{singleForm.formState.errors.provider.message}
)}
+ {selectedProviderSection && (
+
+ {t(selectedProviderSection.hintKey)}
+ {isPlusExtraProvider(selectedProvider)
+ ? ` ${t('providerConfig.plusTrackNote')}`
+ : ''}
+
+ )}
{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) => (
+
))}
+ {compositeTiers?.[tier]?.provider && (
+
+ {t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')}
+ {isPlusExtraProvider(compositeTiers[tier].provider)
+ ? ` ${t('providerConfig.plusTrackNote')}`
+ : ''}
+
+ )}
diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx
index 9274e975..76540428 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) => (
+
))}
+ {compositeTiers?.[tier]?.provider && (
+
+ {t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')}
+ {isPlusExtraProvider(compositeTiers[tier].provider)
+ ? ` ${t('providerConfig.plusTrackNote')}`
+ : ''}
+
+ )}
@@ -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) => (
+
))}
+ {selectedProvider && (
+
+ {t(getProviderSection(selectedProvider)?.hintKey || '')}
+ {isPlusExtraProvider(selectedProvider)
+ ? ` ${t('providerConfig.plusTrackNote')}`
+ : ''}
+
+ )}
diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx
index 72b3d7ef..65be5b3b 100644
--- a/ui/src/components/cliproxy/provider-editor/index.tsx
+++ b/ui/src/components/cliproxy/provider-editor/index.tsx
@@ -304,6 +304,7 @@ export function ProviderEditor({
{resolvedTarget}
+ {providerSection && (
+
+
+ {t('providerConfig.trackLabel')}
+
+
+
{t(providerSection.labelKey)}
+
+ {t(providerSection.hintKey)}
+ {isPlusExtraProvider(sectionProvider)
+ ? ` ${t('providerConfig.plusTrackNote')}`
+ : ''}
+
+
+
+ )}
diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts
index 5329df3a..b74a1194 100644
--- a/ui/src/lib/i18n.ts
+++ b/ui/src/lib/i18n.ts
@@ -977,10 +977,13 @@ const resources = {
backendBinary: 'Backend Binary',
stopProxyToSwitch: 'Stop the running proxy in Instance Status to switch backend.',
default: 'Default',
- plusDesc: 'Full provider support including Kiro and GitHub Copilot',
- originalDesc: 'Original binary (Gemini, Codex, Antigravity only)',
+ plusDesc:
+ 'Optional track for extra providers. Still supported, but currently community-maintained instead of upstream-maintained.',
+ originalDesc: 'Default, always-available backend for the core provider track.',
+ plusFallbackNotice:
+ '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.',
variantsIncompatible:
- 'Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to CLIProxyAPIPlus or remove those variants.',
+ 'Existing plus-extra variants ({{providers}}) will not run on the original backend. Keep them visible for reference, but switch to Plus before using them.',
safety: 'Safety',
agyModeTitle: 'Antigravity + Gemini Power User Mode',
agyModeDesc:
@@ -2234,6 +2237,13 @@ const resources = {
},
providerConfig: {
defaultDeviceCodeInstruction: 'Complete the authorization in your browser.',
+ trackLabel: 'Track',
+ sectionCoreLabel: 'Core / original backend',
+ sectionCoreHint: 'Default, always-available provider track',
+ sectionPlusLabel: 'Plus extras / community-maintained',
+ sectionPlusHint: 'Still supported, but separated from the default backend for now',
+ plusTrackNote:
+ 'Requires the optional Plus backend while that track remains community-maintained.',
},
// ========================================
@@ -3443,10 +3453,12 @@ const resources = {
backendBinary: '后端二进制',
stopProxyToSwitch: '请先在实例状态中停止正在运行的代理,再切换后端。',
default: '默认',
- plusDesc: '完整支持包括 Kiro 和 GitHub Copilot 在内的提供商',
- originalDesc: '原版二进制(仅 Gemini、Codex、Antigravity)',
+ plusDesc: '额外提供商的可选线路。仍受支持,但目前由社区维护而非上游维护。',
+ originalDesc: '核心提供商线路的默认、始终可用后端。',
+ plusFallbackNotice:
+ 'Plus 提供商线路并未弃用,但在受维护的 fork 恢复之前,本地 CLIProxy 仍会回退到原始后端。',
variantsIncompatible:
- '现有 Kiro/Copilot 变体与 CLIProxyAPI 不兼容。请切换到 CLIProxyAPIPlus 或移除这些变体。',
+ '现有 plus 扩展变体({{providers}})无法在原始后端上运行。可以保留作参考,但使用前请切换到 Plus。',
safety: '安全',
agyModeTitle: 'Antigravity + Gemini 高级模式',
agyModeDesc: '跳过 AGY 责任确认清单,以及 Gemini Dashboard 中输入风险短语的步骤。',
@@ -4656,6 +4668,12 @@ const resources = {
},
providerConfig: {
defaultDeviceCodeInstruction: '请在浏览器中完成授权。',
+ trackLabel: '分组',
+ sectionCoreLabel: '核心 / 原始后端',
+ sectionCoreHint: '默认且始终可用的提供商线路',
+ sectionPlusLabel: 'Plus 扩展 / 社区维护',
+ sectionPlusHint: '仍然受支持,但目前与默认后端分开显示',
+ plusTrackNote: '需要可选的 Plus 后端;当前这条线路由社区维护。',
},
profileEditorSections: {
imageAnalysis: '图片分析',
@@ -5934,10 +5952,13 @@ const resources = {
stopProxyToSwitch:
'Dừng proxy đang chạy trong Trạng thái phiên bản trước khi chuyển backend.',
default: 'Mặc định',
- plusDesc: 'Hỗ trợ đầy đủ nhà cung cấp, bao gồm Kiro và GitHub Copilot',
- originalDesc: 'Binary gốc (chỉ Gemini, Codex, Antigravity)',
+ plusDesc:
+ 'Nhánh tùy chọn cho các nhà cung cấp bổ sung. Vẫn được hỗ trợ nhưng hiện do cộng đồng duy trì thay vì upstream.',
+ originalDesc: 'Backend mặc định, luôn sẵn sàng cho nhóm nhà cung cấp cốt lõi.',
+ plusFallbackNotice:
+ 'Nhánh nhà cung cấp Plus chưa bị khai tử, nhưng CLIProxy cục bộ vẫn quay về backend gốc cho tới khi đường dẫn fork được duy trì được bật lại.',
variantsIncompatible:
- 'Các biến thể Kiro/Copilot hiện tại sẽ không hoạt động với CLIProxyAPI. Chuyển sang CLIProxyAPIPlus hoặc xóa các biến thể đó.',
+ 'Các biến thể plus-extra hiện có ({{providers}}) sẽ không chạy trên backend gốc. Có thể giữ lại để tham chiếu, nhưng hãy chuyển sang Plus trước khi dùng.',
safety: 'An toàn',
agyModeTitle: 'Chế độ power user Antigravity + Gemini',
agyModeDesc:
@@ -7170,6 +7191,12 @@ const resources = {
},
providerConfig: {
defaultDeviceCodeInstruction: 'Hoàn tất việc cấp quyền trong trình duyệt của bạn.',
+ trackLabel: 'Nhóm',
+ sectionCoreLabel: 'Core / backend gốc',
+ sectionCoreHint: 'Nhóm nhà cung cấp mặc định, luôn sẵn sàng',
+ sectionPlusLabel: 'Plus extras / cộng đồng duy trì',
+ sectionPlusHint: 'Vẫn được hỗ trợ nhưng hiện được tách khỏi backend mặc định',
+ plusTrackNote: 'Cần backend Plus tùy chọn; hiện tại nhánh này do cộng đồng duy trì.',
},
profileEditorSections: {
imageAnalysis: 'Phân tích hình ảnh',
@@ -8461,10 +8488,13 @@ const resources = {
stopProxyToSwitch:
'バックエンドを切り替える前に、インスタンス状態から実行中のプロキシを停止してください。',
default: 'デフォルト',
- plusDesc: 'Kiro と GitHub Copilot を含むすべてのプロバイダーをサポート',
- originalDesc: '元のバイナリ(Gemini、Codex、Antigravity のみ)',
+ plusDesc:
+ '追加プロバイダー向けのオプショントラックです。引き続きサポートされていますが、現在は upstream ではなくコミュニティ保守です。',
+ originalDesc: 'コアプロバイダートラック向けの既定かつ常時利用可能な backend。',
+ plusFallbackNotice:
+ 'Plus プロバイダートラックは廃止ではありませんが、保守中の fork が戻るまではローカル CLIProxy は引き続きオリジナル backend にフォールバックします。',
variantsIncompatible:
- '既存の Kiro/Copilot バリアントは CLIProxyAPI では動作しません。CLIProxyAPIPlus に切り替えるか、それらのバリアントを削除してください。',
+ '既存の plus-extra バリアント({{providers}})はオリジナル backend では動作しません。参照用に残すことはできますが、使用前に Plus へ切り替えてください。',
safety: '安全設定',
agyModeTitle: 'Antigravity + Gemini パワーユーザーモード',
agyModeDesc:
@@ -9984,6 +10014,14 @@ const resources = {
},
providerConfig: {
defaultDeviceCodeInstruction: 'ブラウザーで認証を完了してください。',
+ trackLabel: 'トラック',
+ sectionCoreLabel: 'コア / オリジナル backend',
+ sectionCoreHint: '既定で常に利用できるプロバイダートラック',
+ sectionPlusLabel: 'Plus 拡張 / コミュニティ保守',
+ sectionPlusHint:
+ '引き続きサポートされていますが、当面は既定の backend から分離されています',
+ plusTrackNote:
+ 'このトラックはオプションの Plus backend が必要で、現在はコミュニティ保守です。',
},
updatesSpotlight: {
openUpdatesCenter: '更新センターを開く',
diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts
index 3500e4a4..00c34cc7 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;
+ labelKey: string;
+ hintKey: 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',
+ labelKey: 'providerConfig.sectionCoreLabel',
+ hintKey: 'providerConfig.sectionCoreHint',
+ providers: CORE_CLIPROXY_PROVIDERS,
+ },
+ {
+ id: 'plus-extra',
+ labelKey: 'providerConfig.sectionPlusLabel',
+ hintKey: 'providerConfig.sectionPlusHint',
+ 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,52 @@ 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
+ );
+}
+
+interface VariantLike {
+ provider?: unknown;
+ tiers?: Record | null;
+}
+
+export function variantUsesPlusExtraProvider(variant: VariantLike | null | undefined): boolean {
+ if (!variant) {
+ return false;
+ }
+
+ if (variant.tiers) {
+ return Object.values(variant.tiers).some((tier) => isPlusExtraProvider(tier?.provider));
+ }
+
+ return isPlusExtraProvider(variant.provider);
+}
+
+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..962591e2 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) => (
+
+
+
+ {t(section.labelKey)}
+
+
+ {t(section.hintKey)}
+
+
+
+ {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..441de0f8 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,
+ variantUsesPlusExtraProvider,
+} 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,9 @@ 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((variant) =>
+ variantUsesPlusExtraProvider(variant)
+ );
setHasKiroGhcpVariants(hasIncompatible);
} catch (err) {
console.error('[Proxy] Failed to check variants:', err);
@@ -492,6 +499,9 @@ export default function ProxySection() {
{t('settingsProxy.backendPlusApi')}
{t('settingsProxy.plusDesc')}
+
+ {plusProviderNames}
+
{/* Original Backend Card */}
@@ -511,14 +521,16 @@ export default function ProxySection() {
{t('settingsProxy.originalDesc')}
+
+ {coreProviderNames}
+
{backend === 'plus' && (
- CLIProxyAPIPlus upstream is currently unavailable. Local CLIProxy will use the
- original backend until issue #1062 is resolved.
+ {t('settingsProxy.plusFallbackNotice')}
)}
@@ -526,7 +538,9 @@ export default function ProxySection() {
{backend === 'original' && hasKiroGhcpVariants && (
- {t('settingsProxy.variantsIncompatible')}
+
+ {t('settingsProxy.variantsIncompatible', { providers: plusProviderNames })}
+
)}
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..1c518ba6 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,48 @@ 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();
+ });
+
+ it('uses the base provider when rendering variant track metadata', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Track')).toBeInTheDocument();
+ expect(screen.getByText('Plus extras / 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..f83ede63 100644
--- a/ui/tests/unit/ui/lib/provider-config.test.ts
+++ b/ui/tests/unit/ui/lib/provider-config.test.ts
@@ -1,15 +1,22 @@
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,
+ variantUsesPlusExtraProvider,
} from '@/lib/provider-config';
describe('provider model mapping helpers', () => {
@@ -49,6 +56,61 @@ 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('detects plus-extra providers inside composite variants', () => {
+ expect(
+ variantUsesPlusExtraProvider({
+ provider: 'gemini',
+ tiers: {
+ opus: { provider: 'gemini' },
+ sonnet: { provider: 'cursor' },
+ },
+ })
+ ).toBe(true);
+ expect(
+ variantUsesPlusExtraProvider({
+ provider: 'gemini',
+ tiers: {
+ opus: { provider: 'gemini' },
+ sonnet: { provider: 'kimi' },
+ },
+ })
+ ).toBe(false);
+ });
+
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'],