mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-18 12:13:58 +00:00
chore: merge origin/dev into pr-1061 fix branch
This commit is contained in:
commit
5ff0370c1c
96 files changed
+3128
-624
No files matched your search
@@ -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}
|
||||
|
||||
@@ -229,7 +229,8 @@ export function ModelConfigSection({
|
||||
{provider === 'codex' && (
|
||||
<p className="text-[11px] text-muted-foreground mb-3 rounded-md border bg-muted/30 px-2.5 py-2">
|
||||
Codex tip: suffixes <code>-medium</code>, <code>-high</code>, and <code>-xhigh</code>{' '}
|
||||
pin reasoning effort. Unsuffixed models use Thinking settings.
|
||||
pin reasoning effort. Select a suffixed model to pin effort; unsuffixed models use
|
||||
Thinking settings.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { CliproxyProviderRoutingHints } from '@/lib/api-client';
|
||||
import { getCodexEffortDisplay } from '@/lib/codex-effort';
|
||||
import type { CodexEffort } from '@/lib/codex-effort';
|
||||
import { getCodexEffortDisplay, getCodexEffortVariants } from '@/lib/codex-effort';
|
||||
import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -35,6 +36,8 @@ export interface ModelEntry {
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
};
|
||||
/** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */
|
||||
codexMaxEffort?: CodexEffort;
|
||||
}
|
||||
|
||||
/** Provider catalog */
|
||||
@@ -313,6 +316,14 @@ function getPreferredOptionValue(
|
||||
return routingHint?.recommendedModelId ?? modelId;
|
||||
}
|
||||
|
||||
function getModelOptionValues(
|
||||
codexMaxEffort: CodexEffort | undefined,
|
||||
optionValue: string,
|
||||
isCodexProvider: boolean
|
||||
): string[] {
|
||||
return isCodexProvider ? getCodexEffortVariants(optionValue, codexMaxEffort) : [optionValue];
|
||||
}
|
||||
|
||||
export function FlexibleModelSelector({
|
||||
label,
|
||||
description,
|
||||
@@ -342,55 +353,64 @@ export function FlexibleModelSelector({
|
||||
const recommendedOptionValues = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
resolvedCatalogModels.map((model) =>
|
||||
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))
|
||||
resolvedCatalogModels.flatMap((model) =>
|
||||
getModelOptionValues(
|
||||
model.codexMaxEffort,
|
||||
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||
isCodexProvider
|
||||
)
|
||||
)
|
||||
),
|
||||
[resolvedCatalogModels, routingHints]
|
||||
[isCodexProvider, resolvedCatalogModels, routingHints]
|
||||
);
|
||||
const selectedRoutingHint = useMemo(
|
||||
() => routingHints.get(normalizeModelValue(value, routing).toLowerCase()),
|
||||
[routing, routingHints, value]
|
||||
);
|
||||
|
||||
const recommendedOptions = resolvedCatalogModels.map((model) => ({
|
||||
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||
groupKey: 'recommended',
|
||||
searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.tier ?? '', catalog?.provider ?? ''],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHints.get(model.id.toLowerCase())?.prefix}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.shadowed')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.prefixOnly')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
const recommendedOptions = resolvedCatalogModels.flatMap((model) => {
|
||||
const routingHint = routingHints.get(model.id.toLowerCase());
|
||||
const optionValues = getModelOptionValues(
|
||||
model.codexMaxEffort,
|
||||
getPreferredOptionValue(model.id, routingHint),
|
||||
isCodexProvider
|
||||
);
|
||||
|
||||
return optionValues.map((optionValue) => ({
|
||||
value: optionValue,
|
||||
groupKey: 'recommended',
|
||||
searchText: `${optionValue} ${model.id} ${model.name} ${routingHint?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.tier ?? '', catalog?.provider ?? ''],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{optionValue}</span>
|
||||
{routingHint?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHint.prefix}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{optionValue}</span>
|
||||
{model.tier === 'paid' && <PaidBadge label={t('providerModelSelector.paid')} />}
|
||||
{routingHint?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.shadowed')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHint?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.prefixOnly')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
});
|
||||
|
||||
const allModelOptions = supplementalModels
|
||||
.filter((model) => !catalogModelIds.has(model.id))
|
||||
@@ -400,43 +420,48 @@ export function FlexibleModelSelector({
|
||||
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))
|
||||
)
|
||||
)
|
||||
.map((model) => ({
|
||||
value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||
groupKey: 'all',
|
||||
searchText: `${model.id} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.owned_by],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHints.get(model.id.toLowerCase())?.prefix}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">
|
||||
{getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))}
|
||||
</span>
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.shadowed')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.prefixOnly')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={model.id} />}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
.flatMap((model) => {
|
||||
const routingHint = routingHints.get(model.id.toLowerCase());
|
||||
const optionValues = getModelOptionValues(
|
||||
undefined,
|
||||
getPreferredOptionValue(model.id, routingHint),
|
||||
isCodexProvider
|
||||
);
|
||||
|
||||
return optionValues.map((optionValue) => ({
|
||||
value: optionValue,
|
||||
groupKey: 'all',
|
||||
searchText: `${optionValue} ${model.id} ${routingHint?.recommendedModelId ?? ''}`,
|
||||
keywords: [model.owned_by],
|
||||
triggerContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{optionValue}</span>
|
||||
{routingHint?.pinnedAvailable ? (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1 uppercase">
|
||||
{routingHint.prefix}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||
</div>
|
||||
),
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{optionValue}</span>
|
||||
{routingHint?.unprefixedStatus === 'shadowed' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.shadowed')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{routingHint?.unprefixedStatus === 'prefix-only' ? (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.prefixOnly')}
|
||||
</Badge>
|
||||
) : null}
|
||||
{isCodexProvider && <CodexEffortBadge modelId={optionValue} />}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
});
|
||||
const selectedValueMissing =
|
||||
Boolean(value) &&
|
||||
!recommendedOptions.some((option) => option.value === value) &&
|
||||
|
||||
@@ -1,14 +1,56 @@
|
||||
export type CodexEffort = 'medium' | 'high' | 'xhigh';
|
||||
|
||||
const CODEX_EFFORT_SUFFIX_REGEX = /-(medium|high|xhigh)$/i;
|
||||
const CODEX_EFFORTS_IN_ORDER: readonly CodexEffort[] = ['medium', 'high', 'xhigh'];
|
||||
|
||||
function trimModelId(modelId: string | undefined): string {
|
||||
return modelId?.trim() ?? '';
|
||||
}
|
||||
|
||||
export function parseCodexEffort(modelId: string | undefined): CodexEffort | undefined {
|
||||
if (!modelId) return undefined;
|
||||
const match = modelId.trim().match(CODEX_EFFORT_SUFFIX_REGEX);
|
||||
const match = trimModelId(modelId).match(CODEX_EFFORT_SUFFIX_REGEX);
|
||||
if (!match?.[1]) return undefined;
|
||||
return match[1].toLowerCase() as CodexEffort;
|
||||
}
|
||||
|
||||
export function stripCodexEffortSuffix(modelId: string | undefined): string {
|
||||
return trimModelId(modelId).replace(CODEX_EFFORT_SUFFIX_REGEX, '');
|
||||
}
|
||||
|
||||
export function applyCodexEffortSuffix(
|
||||
modelId: string | undefined,
|
||||
effort: CodexEffort | undefined
|
||||
): string {
|
||||
const normalizedModelId = stripCodexEffortSuffix(modelId);
|
||||
if (!normalizedModelId || !effort) {
|
||||
return normalizedModelId;
|
||||
}
|
||||
return `${normalizedModelId}-${effort}`;
|
||||
}
|
||||
|
||||
export function getCodexEffortVariants(
|
||||
modelId: string,
|
||||
maxEffort: CodexEffort | undefined
|
||||
): string[] {
|
||||
if (!maxEffort) {
|
||||
const explicitEffort = parseCodexEffort(modelId);
|
||||
return [applyCodexEffortSuffix(modelId, explicitEffort)];
|
||||
}
|
||||
|
||||
const normalizedModelId = stripCodexEffortSuffix(modelId);
|
||||
const variantIds = [normalizedModelId];
|
||||
|
||||
for (const effort of CODEX_EFFORTS_IN_ORDER) {
|
||||
variantIds.push(applyCodexEffortSuffix(normalizedModelId, effort));
|
||||
if (effort === maxEffort) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return variantIds;
|
||||
}
|
||||
|
||||
export function getCodexEffortDisplay(
|
||||
modelId: string | undefined,
|
||||
effortLabels?: { pinned: (effort: string) => string; auto: string }
|
||||
|
||||
+50
-12
@@ -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.',
|
||||
},
|
||||
|
||||
// ========================================
|
||||
@@ -3455,10 +3465,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 中输入风险短语的步骤。',
|
||||
@@ -4668,6 +4680,12 @@ const resources = {
|
||||
},
|
||||
providerConfig: {
|
||||
defaultDeviceCodeInstruction: '请在浏览器中完成授权。',
|
||||
trackLabel: '分组',
|
||||
sectionCoreLabel: '核心 / 原始后端',
|
||||
sectionCoreHint: '默认且始终可用的提供商线路',
|
||||
sectionPlusLabel: 'Plus 扩展 / 社区维护',
|
||||
sectionPlusHint: '仍然受支持,但目前与默认后端分开显示',
|
||||
plusTrackNote: '需要可选的 Plus 后端;当前这条线路由社区维护。',
|
||||
},
|
||||
profileEditorSections: {
|
||||
imageAnalysis: '图片分析',
|
||||
@@ -5958,10 +5976,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:
|
||||
@@ -7194,6 +7215,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',
|
||||
@@ -8497,10 +8524,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:
|
||||
@@ -10032,6 +10062,14 @@ const resources = {
|
||||
},
|
||||
providerConfig: {
|
||||
defaultDeviceCodeInstruction: 'ブラウザーで認証を完了してください。',
|
||||
trackLabel: 'トラック',
|
||||
sectionCoreLabel: 'コア / オリジナル backend',
|
||||
sectionCoreHint: '既定で常に利用できるプロバイダートラック',
|
||||
sectionPlusLabel: 'Plus 拡張 / コミュニティ保守',
|
||||
sectionPlusHint:
|
||||
'引き続きサポートされていますが、当面は既定の backend から分離されています',
|
||||
plusTrackNote:
|
||||
'このトラックはオプションの Plus backend が必要で、現在はコミュニティ保守です。',
|
||||
},
|
||||
updatesSpotlight: {
|
||||
openUpdatesCenter: '更新センターを開く',
|
||||
|
||||
@@ -263,6 +263,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5-codex',
|
||||
name: 'GPT-5 Codex',
|
||||
description: 'Cross-plan safe Codex default',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5-codex',
|
||||
opus: 'gpt-5-codex',
|
||||
@@ -274,6 +275,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5-codex-mini',
|
||||
name: 'GPT-5 Codex Mini',
|
||||
description: 'Faster and cheaper Codex option',
|
||||
codexMaxEffort: 'high',
|
||||
presetMapping: {
|
||||
default: 'gpt-5-codex-mini',
|
||||
opus: 'gpt-5-codex',
|
||||
@@ -285,6 +287,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5-mini',
|
||||
name: 'GPT-5 Mini',
|
||||
description: 'Legacy mini model ID kept for backwards compatibility',
|
||||
codexMaxEffort: 'high',
|
||||
presetMapping: {
|
||||
default: 'gpt-5-mini',
|
||||
opus: 'gpt-5-codex',
|
||||
@@ -296,6 +299,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'GPT-5.1 Codex Mini',
|
||||
description: 'Legacy fast Codex mini model',
|
||||
codexMaxEffort: 'high',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.1-codex-mini',
|
||||
opus: 'gpt-5.1-codex-max',
|
||||
@@ -307,6 +311,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5.1-codex-max',
|
||||
name: 'GPT-5.1 Codex Max',
|
||||
description: 'Higher-effort Codex model with xhigh support',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.1-codex-max',
|
||||
opus: 'gpt-5.1-codex-max',
|
||||
@@ -318,6 +323,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
id: 'gpt-5.2-codex',
|
||||
name: 'GPT-5.2 Codex',
|
||||
description: 'Cross-plan Codex model with xhigh support',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.2-codex',
|
||||
opus: 'gpt-5.2-codex',
|
||||
@@ -330,6 +336,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
name: 'GPT-5.3 Codex',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.3-codex',
|
||||
opus: 'gpt-5.3-codex',
|
||||
@@ -342,6 +349,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
name: 'GPT-5.3 Codex Spark',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only, ultra-fast coding model',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.3-codex-spark',
|
||||
opus: 'gpt-5.3-codex',
|
||||
@@ -354,6 +362,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
name: 'GPT-5.4',
|
||||
tier: 'paid',
|
||||
description: 'Paid Codex plans only, latest GPT-5 family model',
|
||||
codexMaxEffort: 'xhigh',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.4',
|
||||
opus: 'gpt-5.4',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
function normalizeRiskAckPhrase(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ').toUpperCase();
|
||||
}
|
||||
@@ -191,11 +194,13 @@ export default function ProxySection() {
|
||||
}, [isAgyConfirmPhraseValid, persistAgyAckBypass, t]);
|
||||
|
||||
// Backend state (loaded from API) + mutation hook for proper query invalidation
|
||||
const [backend, setBackend] = useState<'original' | 'plus'>('plus');
|
||||
const [backend, setBackend] = useState<'original' | 'plus'>('original');
|
||||
const [hasKiroGhcpVariants, setHasKiroGhcpVariants] = useState(false);
|
||||
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);
|
||||
@@ -490,11 +497,11 @@ export default function ProxySection() {
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-medium">{t('settingsProxy.backendPlusApi')}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
|
||||
{t('settingsProxy.default')}
|
||||
</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 */}
|
||||
@@ -509,15 +516,31 @@ export default function ProxySection() {
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-medium">{t('settingsProxy.backendApi')}</span>
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400">
|
||||
{t('settingsProxy.default')}
|
||||
</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">
|
||||
{t('settingsProxy.plusFallbackNotice')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* Warning when original backend selected with Kiro/ghcp variants */}
|
||||
{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>
|
||||
|
||||
@@ -32,6 +32,7 @@ const THINKING_LEVELS = [
|
||||
{ value: 'medium', label: 'Medium (8K tokens)' },
|
||||
{ value: 'high', label: 'High (24K tokens)' },
|
||||
{ value: 'xhigh', label: 'Extra High (32K tokens)' },
|
||||
{ value: 'max', label: 'Max (adaptive ceiling)' },
|
||||
{ value: 'auto', label: 'Auto (dynamic)' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -151,4 +151,49 @@ describe('useProviderEditor', () => {
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves explicit codex effort suffixes in editor state updates', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url.includes('/api/settings/codex/raw')) {
|
||||
return Promise.resolve(
|
||||
createJsonResponse({
|
||||
profile: 'codex',
|
||||
settings: {
|
||||
env: {
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium',
|
||||
},
|
||||
},
|
||||
mtime: 1,
|
||||
path: '~/.ccs/codex.settings.json',
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useProviderEditor('codex'), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.currentModel).toBe('gpt-5.3-codex-high'));
|
||||
|
||||
act(() => {
|
||||
result.current.updateEnvValue('ANTHROPIC_MODEL', 'gpt-5.3-codex-xhigh');
|
||||
});
|
||||
|
||||
const nextSettings = JSON.parse(result.current.rawJsonContent);
|
||||
expect(nextSettings.env).toMatchObject({
|
||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.4-mini-medium',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FlexibleModelSelector } from '@/components/cliproxy/provider-model-selector';
|
||||
import { buildUiCatalogs } from '@/lib/model-catalogs';
|
||||
import { MODEL_CATALOGS, buildUiCatalogs } from '@/lib/model-catalogs';
|
||||
import { render, screen, userEvent } from '@tests/setup/test-utils';
|
||||
|
||||
const noisyAgyModels = [
|
||||
@@ -87,4 +87,68 @@ describe('FlexibleModelSelector', () => {
|
||||
expect(screen.getAllByText('gemini-3.1-pro-preview').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers codex effort-suffixed variants as first-class selectable options', async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Primary model"
|
||||
value={undefined}
|
||||
onChange={onChange}
|
||||
catalog={MODEL_CATALOGS.codex}
|
||||
allModels={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
|
||||
|
||||
expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('gpt-5.3-codex-high'));
|
||||
expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high');
|
||||
});
|
||||
|
||||
it('does not relegate saved codex effort variants to the legacy current-value fallback', async () => {
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Primary model"
|
||||
value="gpt-5.3-codex-high"
|
||||
onChange={vi.fn()}
|
||||
catalog={MODEL_CATALOGS.codex}
|
||||
allModels={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /gpt-5\.3-codex-high/i }));
|
||||
|
||||
expect(screen.queryByText('Current value')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.3-codex')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-5.3-codex-high').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('preserves explicit suffixes on supplemental codex models outside the static catalog', async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Primary model"
|
||||
value={undefined}
|
||||
onChange={onChange}
|
||||
catalog={MODEL_CATALOGS.codex}
|
||||
allModels={[{ id: 'gpt-5.5-codex-high', owned_by: 'openai' }]}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
|
||||
|
||||
expect(screen.getByText(/All Models \(1\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.5-codex-high')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('gpt-5.5-codex-high'));
|
||||
expect(onChange).toHaveBeenCalledWith('gpt-5.5-codex-high');
|
||||
});
|
||||
});
|
||||
@@ -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,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getCodexEffortDisplay, parseCodexEffort } from '@/lib/codex-effort';
|
||||
import {
|
||||
applyCodexEffortSuffix,
|
||||
getCodexEffortDisplay,
|
||||
getCodexEffortVariants,
|
||||
parseCodexEffort,
|
||||
stripCodexEffortSuffix,
|
||||
} from '@/lib/codex-effort';
|
||||
|
||||
describe('parseCodexEffort', () => {
|
||||
it('parses lowercase suffixes', () => {
|
||||
@@ -38,3 +44,25 @@ describe('getCodexEffortDisplay', () => {
|
||||
expect(getCodexEffortDisplay(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('codex effort helpers', () => {
|
||||
it('strips and reapplies codex effort suffixes', () => {
|
||||
expect(stripCodexEffortSuffix('gpt-5.3-codex-high')).toBe('gpt-5.3-codex');
|
||||
expect(applyCodexEffortSuffix('gpt-5.3-codex-high', 'xhigh')).toBe('gpt-5.3-codex-xhigh');
|
||||
expect(applyCodexEffortSuffix('gpt-5.3-codex', undefined)).toBe('gpt-5.3-codex');
|
||||
});
|
||||
|
||||
it('builds ordered codex effort variants up to the supported max level', () => {
|
||||
expect(getCodexEffortVariants('gpt-5.3-codex', 'xhigh')).toEqual([
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.3-codex-medium',
|
||||
'gpt-5.3-codex-high',
|
||||
'gpt-5.3-codex-xhigh',
|
||||
]);
|
||||
expect(getCodexEffortVariants('gpt-5.4-mini', 'high')).toEqual([
|
||||
'gpt-5.4-mini',
|
||||
'gpt-5.4-mini-medium',
|
||||
'gpt-5.4-mini-high',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,11 @@ describe('codex model catalog defaults', () => {
|
||||
const codexCatalog = MODEL_CATALOGS.codex;
|
||||
const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex');
|
||||
const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2-codex');
|
||||
const codexMini = codexCatalog.models.find((model) => model.id === 'gpt-5-codex-mini');
|
||||
|
||||
expect(codex53?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
|
||||
expect(codex52?.presetMapping?.haiku).toBe('gpt-5-codex-mini');
|
||||
expect(codex53?.codexMaxEffort).toBe('xhigh');
|
||||
expect(codexMini?.codexMaxEffort).toBe('high');
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
|
||||
Reference in new issue
Block a user