Merge pull request #1072 from kaitranntt/kai/feat/1065-provider-sections

feat(cliproxy): separate core and plus provider sections
This commit is contained in:
Kai (Tam Nhu) Tran
2026-04-22 21:08:25 -04:00
committed by GitHub
12 changed files with 463 additions and 58 deletions
@@ -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));
+45 -9
View File
@@ -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"
>
<option value="">{t('cliproxyDialog.selectProvider')}</option>
{providerOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
<optgroup key={section.id} label={t(section.labelKey)}>
{providerOptions
.filter((opt) => section.providers.includes(opt.value))
.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
{singleForm.formState.errors.provider && (
@@ -208,6 +222,14 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
{singleForm.formState.errors.provider.message}
</span>
)}
{selectedProviderSection && (
<p className="mt-2 text-xs text-muted-foreground">
{t(selectedProviderSection.hintKey)}
{isPlusExtraProvider(selectedProvider)
? ` ${t('providerConfig.plusTrackNote')}`
: ''}
</p>
)}
</div>
{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) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
<optgroup key={section.id} label={t(section.labelKey)}>
{providerOptions
.filter((opt) => section.providers.includes(opt.value))
.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
{compositeTiers?.[tier]?.provider && (
<p className="mt-2 text-xs text-muted-foreground">
{t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')}
{isPlusExtraProvider(compositeTiers[tier].provider)
? ` ${t('providerConfig.plusTrackNote')}`
: ''}
</p>
)}
</div>
<div>
<Label htmlFor={`${tier}-model`}>{t('cliproxyDialog.model')}</Label>
@@ -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<CompositeFormData>({
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) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
<optgroup key={section.id} label={t(section.labelKey)}>
{providerOptions
.filter((opt) => section.providers.includes(opt.value))
.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
{compositeTiers?.[tier]?.provider && (
<p className="mt-2 text-xs text-muted-foreground">
{t(getProviderSection(compositeTiers[tier].provider)?.hintKey || '')}
{isPlusExtraProvider(compositeTiers[tier].provider)
? ` ${t('providerConfig.plusTrackNote')}`
: ''}
</p>
)}
</div>
<div>
<Label htmlFor={`edit-${tier}-model`}>{t('cliproxyDialog.model')}</Label>
@@ -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) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
{CLIPROXY_PROVIDER_SECTIONS.map((section) => (
<optgroup key={section.id} label={t(section.labelKey)}>
{providerOptions
.filter((opt) => section.providers.includes(opt.value))
.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</optgroup>
))}
</select>
{selectedProvider && (
<p className="mt-2 text-xs text-muted-foreground">
{t(getProviderSection(selectedProvider)?.hintKey || '')}
{isPlusExtraProvider(selectedProvider)
? ` ${t('providerConfig.plusTrackNote')}`
: ''}
</p>
)}
</div>
<div>
@@ -304,6 +304,7 @@ export function ProviderEditor({
<ProviderInfoTab
provider={provider}
displayName={displayName}
baseProvider={baseProvider}
defaultTarget={defaultTarget}
data={data}
authStatus={authStatus}
@@ -10,11 +10,13 @@ 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 {
provider: string;
displayName: string;
baseProvider?: string;
defaultTarget?: CliTarget;
data?: SettingsResponse;
authStatus: AuthStatus;
@@ -24,6 +26,7 @@ interface ProviderInfoTabProps {
export function ProviderInfoTab({
provider,
displayName,
baseProvider,
defaultTarget,
data,
authStatus,
@@ -33,6 +36,8 @@ export function ProviderInfoTab({
const resolvedTarget = defaultTarget || 'claude';
const isDroidTarget = resolvedTarget === 'droid';
const isCodexProvider = provider === 'codex';
const sectionProvider = baseProvider || authStatus.provider || provider;
const providerSection = getProviderSection(sectionProvider);
const managementPrefix =
resolvedTarget === 'claude' ? `ccs ${provider}` : `ccs ${provider} --target claude`;
const changeModelCommand = `${managementPrefix} --config`;
@@ -101,6 +106,22 @@ export function ProviderInfoTab({
</span>
<span className="font-mono">{resolvedTarget}</span>
</div>
{providerSection && (
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
<span className="font-medium text-muted-foreground">
{t('providerConfig.trackLabel')}
</span>
<div className="space-y-1">
<span className="font-mono">{t(providerSection.labelKey)}</span>
<p className="text-xs text-muted-foreground">
{t(providerSection.hintKey)}
{isPlusExtraProvider(sectionProvider)
? ` ${t('providerConfig.plusTrackNote')}`
: ''}
</p>
</div>
</div>
)}
</div>
</div>
+50 -12
View File
@@ -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: '更新センターを開く',
+80
View File
@@ -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<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',
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<string, { provider?: unknown } | undefined> | 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<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 */
export function getProviderDescription(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
+31 -9
View File
@@ -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() {
))}
</div>
) : (
<div className="space-y-1">
{providers.map((status) => (
<ProviderSidebarItem
key={status.provider}
status={status}
isSelected={effectiveProvider === status.provider}
onSelect={() => handleSelectProvider(status.provider)}
/>
<div className="space-y-4">
{providerSections.map((section) => (
<div key={section.id} className="space-y-1">
<div className="px-3">
<div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t(section.labelKey)}
</div>
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
{t(section.hintKey)}
</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>
)}
+21 -7
View File
@@ -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() {
<span className="font-medium">{t('settingsProxy.backendPlusApi')}</span>
</div>
<p className="text-xs text-muted-foreground">{t('settingsProxy.plusDesc')}</p>
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
{plusProviderNames}
</p>
</button>
{/* Original Backend Card */}
@@ -511,14 +521,16 @@ export default function ProxySection() {
</span>
</div>
<p className="text-xs text-muted-foreground">{t('settingsProxy.originalDesc')}</p>
<p className="mt-2 text-[11px] leading-relaxed text-muted-foreground">
{coreProviderNames}
</p>
</button>
</div>
{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">
<AlertTriangle className="h-4 w-4 text-amber-600" />
<AlertDescription className="text-amber-700 dark:text-amber-400">
CLIProxyAPIPlus upstream is currently unavailable. Local CLIProxy will use the
original backend until issue #1062 is resolved.
{t('settingsProxy.plusFallbackNotice')}
</AlertDescription>
</Alert>
)}
@@ -526,7 +538,9 @@ export default function ProxySection() {
{backend === 'original' && hasKiroGhcpVariants && (
<Alert variant="destructive" className="py-2">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>{t('settingsProxy.variantsIncompatible')}</AlertDescription>
<AlertDescription>
{t('settingsProxy.variantsIncompatible', { providers: plusProviderNames })}
</AlertDescription>
</Alert>
)}
</div>
@@ -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(
<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();
});
it('uses the base provider when rendering variant track metadata', () => {
render(
<ProviderInfoTab
provider="my-cursor"
baseProvider="cursor"
displayName="My Cursor Variant"
defaultTarget="claude"
authStatus={{
...authenticatedStatus,
provider: 'cursor',
displayName: 'Cursor',
}}
supportsModelConfig
/>
);
expect(screen.getByText('Track')).toBeInTheDocument();
expect(screen.getByText('Plus extras / community-maintained')).toBeInTheDocument();
});
});
+48 -11
View File
@@ -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(<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 () => {
render(<CliproxyPage />);
@@ -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'],