mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
feat: support custom GHCP model selection
This commit is contained in:
1 parent
1aacbfd2a8
commit
631a515986
7 files changed
+324
-26
No files matched your search
@@ -72,6 +72,17 @@ interface ProviderModelSelectorProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CUSTOM_MODEL_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/;
|
||||||
|
const CUSTOM_MODEL_ID_SEPARATOR_PATTERN = /[._:/-]/;
|
||||||
|
|
||||||
|
function isCustomModelId(value: string): boolean {
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
return (
|
||||||
|
CUSTOM_MODEL_ID_PATTERN.test(trimmedValue) &&
|
||||||
|
CUSTOM_MODEL_ID_SEPARATOR_PATTERN.test(trimmedValue)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PaidBadge({ label }: { label: string }) {
|
function PaidBadge({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||||
@@ -396,6 +407,25 @@ export function FlexibleModelSelector({
|
|||||||
() => routingHints.get(normalizeModelValue(value, routing).toLowerCase()),
|
() => routingHints.get(normalizeModelValue(value, routing).toLowerCase()),
|
||||||
[routing, routingHints, value]
|
[routing, routingHints, value]
|
||||||
);
|
);
|
||||||
|
const createCustomModelOption = (query: string) => {
|
||||||
|
const customModelId = query.trim();
|
||||||
|
if (!isCustomModelId(customModelId)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: customModelId,
|
||||||
|
groupKey: 'custom',
|
||||||
|
searchText: customModelId,
|
||||||
|
triggerContent: <span className="truncate font-mono text-xs">{customModelId}</span>,
|
||||||
|
itemContent: (
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate font-mono text-xs">{customModelId}</span>
|
||||||
|
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||||
|
{t('providerModelSelector.customModel')}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const recommendedOptions = resolvedCatalogModels.flatMap((model) => {
|
const recommendedOptions = resolvedCatalogModels.flatMap((model) => {
|
||||||
const routingHint = routingHints.get(model.id.toLowerCase());
|
const routingHint = routingHints.get(model.id.toLowerCase());
|
||||||
@@ -544,6 +574,7 @@ export function FlexibleModelSelector({
|
|||||||
: t('providerModelSelector.noModelsAvailable')
|
: t('providerModelSelector.noModelsAvailable')
|
||||||
}
|
}
|
||||||
triggerClassName="h-9"
|
triggerClassName="h-9"
|
||||||
|
createOption={createCustomModelOption}
|
||||||
groups={[
|
groups={[
|
||||||
...(selectedValueMissing && legacySelectedOption
|
...(selectedValueMissing && legacySelectedOption
|
||||||
? [
|
? [
|
||||||
@@ -597,6 +628,14 @@ export function FlexibleModelSelector({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
{
|
||||||
|
key: 'custom',
|
||||||
|
label: (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('providerModelSelector.customModel')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
options={[
|
options={[
|
||||||
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
|
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface SearchableSelectProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
options: SearchableSelectOption[];
|
options: SearchableSelectOption[];
|
||||||
|
createOption?: (query: string) => SearchableSelectOption | null | undefined;
|
||||||
groups?: SearchableSelectGroup[];
|
groups?: SearchableSelectGroup[];
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
searchPlaceholder: string;
|
searchPlaceholder: string;
|
||||||
@@ -47,6 +48,7 @@ export function SearchableSelect({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
|
createOption,
|
||||||
groups,
|
groups,
|
||||||
placeholder,
|
placeholder,
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
@@ -64,20 +66,32 @@ export function SearchableSelect({
|
|||||||
const optionRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});
|
const optionRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});
|
||||||
|
|
||||||
const selectedOption = React.useMemo(
|
const selectedOption = React.useMemo(
|
||||||
() => options.find((option) => option.value === value),
|
() =>
|
||||||
[options, value]
|
options.find((option) => option.value === value) ??
|
||||||
|
(value ? (createOption?.(value) ?? undefined) : undefined),
|
||||||
|
[createOption, options, value]
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredOptions = React.useMemo(() => {
|
const filteredOptions = React.useMemo(() => {
|
||||||
const normalizedQuery = normalizeSearch(query);
|
const normalizedQuery = normalizeSearch(query);
|
||||||
if (!normalizedQuery) return options;
|
if (!normalizedQuery) return options;
|
||||||
|
|
||||||
return options.filter((option) =>
|
const matchedOptions = options.filter((option) =>
|
||||||
[option.searchText, ...(option.keywords ?? [])].some((candidate) =>
|
[option.searchText, ...(option.keywords ?? [])].some((candidate) =>
|
||||||
normalizeSearch(candidate).includes(normalizedQuery)
|
normalizeSearch(candidate).includes(normalizedQuery)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}, [options, query]);
|
const createdOption = createOption?.(query.trim()) ?? null;
|
||||||
|
if (!createdOption) return matchedOptions;
|
||||||
|
|
||||||
|
const normalizedCreatedValue = normalizeSearch(createdOption.value);
|
||||||
|
const optionAlreadyExists = options.some(
|
||||||
|
(option) => normalizeSearch(option.value) === normalizedCreatedValue
|
||||||
|
);
|
||||||
|
if (optionAlreadyExists) return matchedOptions;
|
||||||
|
|
||||||
|
return [...matchedOptions, createdOption];
|
||||||
|
}, [createOption, options, query]);
|
||||||
|
|
||||||
const groupedOptions = React.useMemo(() => {
|
const groupedOptions = React.useMemo(() => {
|
||||||
const knownGroups = new Map((groups ?? []).map((group) => [group.key, group]));
|
const knownGroups = new Map((groups ?? []).map((group) => [group.key, group]));
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ const resources = {
|
|||||||
prefixOnly: 'Prefix only',
|
prefixOnly: 'Prefix only',
|
||||||
current: 'Current',
|
current: 'Current',
|
||||||
currentValue: 'Current value',
|
currentValue: 'Current value',
|
||||||
|
customModel: 'Custom model',
|
||||||
preferredPinnedModel: 'Preferred pinned model:',
|
preferredPinnedModel: 'Preferred pinned model:',
|
||||||
pinnedRouteStatus: 'Pinned route status:',
|
pinnedRouteStatus: 'Pinned route status:',
|
||||||
pinnedModelNotAdvertised:
|
pinnedModelNotAdvertised:
|
||||||
@@ -2947,6 +2948,7 @@ const resources = {
|
|||||||
prefixOnly: '仅前缀',
|
prefixOnly: '仅前缀',
|
||||||
current: '当前',
|
current: '当前',
|
||||||
currentValue: '当前值',
|
currentValue: '当前值',
|
||||||
|
customModel: '自定义模型',
|
||||||
preferredPinnedModel: '偏好的固定模型:',
|
preferredPinnedModel: '偏好的固定模型:',
|
||||||
pinnedRouteStatus: '固定路由状态:',
|
pinnedRouteStatus: '固定路由状态:',
|
||||||
pinnedModelNotAdvertised: '固定模型当前未被代理广播:{{model}}',
|
pinnedModelNotAdvertised: '固定模型当前未被代理广播:{{model}}',
|
||||||
@@ -5555,6 +5557,7 @@ const resources = {
|
|||||||
prefixOnly: 'Chỉ tiền tố',
|
prefixOnly: 'Chỉ tiền tố',
|
||||||
current: 'Hiện tại',
|
current: 'Hiện tại',
|
||||||
currentValue: 'Giá trị hiện tại',
|
currentValue: 'Giá trị hiện tại',
|
||||||
|
customModel: 'Mô hình tùy chỉnh',
|
||||||
preferredPinnedModel: 'Mô hình ghim ưu tiên:',
|
preferredPinnedModel: 'Mô hình ghim ưu tiên:',
|
||||||
pinnedRouteStatus: 'Trạng thái tuyến ghim:',
|
pinnedRouteStatus: 'Trạng thái tuyến ghim:',
|
||||||
pinnedModelNotAdvertised: 'Mô hình ghim hiện không được proxy quảng bá: {{model}}',
|
pinnedModelNotAdvertised: 'Mô hình ghim hiện không được proxy quảng bá: {{model}}',
|
||||||
@@ -8279,6 +8282,7 @@ const resources = {
|
|||||||
prefixOnly: 'プレフィックスのみ',
|
prefixOnly: 'プレフィックスのみ',
|
||||||
current: '現在',
|
current: '現在',
|
||||||
currentValue: '現在の値',
|
currentValue: '現在の値',
|
||||||
|
customModel: 'カスタムモデル',
|
||||||
preferredPinnedModel: '優先ピンモデル:',
|
preferredPinnedModel: '優先ピンモデル:',
|
||||||
pinnedRouteStatus: 'ピンルートの状態:',
|
pinnedRouteStatus: 'ピンルートの状態:',
|
||||||
pinnedModelNotAdvertised: 'ピンモデルは現在プロキシから通知されていません: {{model}}',
|
pinnedModelNotAdvertised: 'ピンモデルは現在プロキシから通知されていません: {{model}}',
|
||||||
@@ -11026,6 +11030,7 @@ const resources = {
|
|||||||
prefixOnly: '접두사 전용',
|
prefixOnly: '접두사 전용',
|
||||||
current: '현재',
|
current: '현재',
|
||||||
currentValue: '현재 값',
|
currentValue: '현재 값',
|
||||||
|
customModel: '사용자 지정 모델',
|
||||||
preferredPinnedModel: '선호하는 고정 모델:',
|
preferredPinnedModel: '선호하는 고정 모델:',
|
||||||
pinnedRouteStatus: '고정된 라우트 상태:',
|
pinnedRouteStatus: '고정된 라우트 상태:',
|
||||||
pinnedModelNotAdvertised: '고정된 모델이 현재 프록시에서 알리지 않고 있습니다: {{model}}',
|
pinnedModelNotAdvertised: '고정된 모델이 현재 프록시에서 알리지 않고 있습니다: {{model}}',
|
||||||
|
|||||||
+126
-22
@@ -680,8 +680,50 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
|||||||
ghcp: {
|
ghcp: {
|
||||||
provider: 'ghcp',
|
provider: 'ghcp',
|
||||||
displayName: 'GitHub Copilot (OAuth)',
|
displayName: 'GitHub Copilot (OAuth)',
|
||||||
defaultModel: 'claude-sonnet-4.5',
|
defaultModel: 'claude-sonnet-5',
|
||||||
models: [
|
models: [
|
||||||
|
{
|
||||||
|
id: 'claude-sonnet-5',
|
||||||
|
name: 'Claude Sonnet 5',
|
||||||
|
description: 'Anthropic Claude Sonnet 5 via GitHub Copilot',
|
||||||
|
extendedContext: true,
|
||||||
|
presetMapping: {
|
||||||
|
default: 'claude-sonnet-5',
|
||||||
|
opus: 'claude-opus-4.8',
|
||||||
|
sonnet: 'claude-sonnet-5',
|
||||||
|
haiku: 'claude-haiku-4.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-opus-4.8',
|
||||||
|
name: 'Claude Opus 4.8',
|
||||||
|
description: 'Anthropic Claude Opus 4.8 via GitHub Copilot',
|
||||||
|
extendedContext: true,
|
||||||
|
presetMapping: {
|
||||||
|
default: 'claude-opus-4.8',
|
||||||
|
opus: 'claude-opus-4.8',
|
||||||
|
sonnet: 'claude-sonnet-5',
|
||||||
|
haiku: 'claude-haiku-4.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-opus-4.8-fast-mode',
|
||||||
|
name: 'Claude Opus 4.8 Fast Mode',
|
||||||
|
description: 'Anthropic Claude Opus 4.8 preview fast mode via GitHub Copilot',
|
||||||
|
extendedContext: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-opus-4.7',
|
||||||
|
name: 'Claude Opus 4.7',
|
||||||
|
description: 'Anthropic Claude Opus 4.7 via GitHub Copilot',
|
||||||
|
extendedContext: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-opus-4.6',
|
||||||
|
name: 'Claude Opus 4.6',
|
||||||
|
description: 'Anthropic Claude Opus 4.6 via GitHub Copilot',
|
||||||
|
extendedContext: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'claude-opus-4.5',
|
id: 'claude-opus-4.5',
|
||||||
name: 'Claude Opus 4.5',
|
name: 'Claude Opus 4.5',
|
||||||
@@ -693,6 +735,18 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
|||||||
haiku: 'claude-haiku-4.5',
|
haiku: 'claude-haiku-4.5',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'claude-sonnet-4.6',
|
||||||
|
name: 'Claude Sonnet 4.6',
|
||||||
|
description: 'Anthropic Claude Sonnet 4.6 via GitHub Copilot',
|
||||||
|
extendedContext: true,
|
||||||
|
presetMapping: {
|
||||||
|
default: 'claude-sonnet-4.6',
|
||||||
|
opus: 'claude-opus-4.7',
|
||||||
|
sonnet: 'claude-sonnet-4.6',
|
||||||
|
haiku: 'claude-haiku-4.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'claude-sonnet-4.5',
|
id: 'claude-sonnet-4.5',
|
||||||
name: 'Claude Sonnet 4.5',
|
name: 'Claude Sonnet 4.5',
|
||||||
@@ -704,36 +758,56 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
|||||||
haiku: 'claude-haiku-4.5',
|
haiku: 'claude-haiku-4.5',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'claude-sonnet-4',
|
|
||||||
name: 'Claude Sonnet 4',
|
|
||||||
description: 'Anthropic Claude Sonnet 4 via GitHub Copilot',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'claude-haiku-4.5',
|
id: 'claude-haiku-4.5',
|
||||||
name: 'Claude Haiku 4.5',
|
name: 'Claude Haiku 4.5',
|
||||||
description: 'Anthropic Claude Haiku 4.5 via GitHub Copilot',
|
description: 'Anthropic Claude Haiku 4.5 via GitHub Copilot',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gpt-5.2',
|
id: 'claude-fable-5',
|
||||||
name: 'GPT-5.2',
|
name: 'Claude Fable 5',
|
||||||
description: 'OpenAI GPT-5.2 via GitHub Copilot',
|
description: 'Anthropic Claude Fable 5 via GitHub Copilot',
|
||||||
|
extendedContext: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpt-5.5',
|
||||||
|
name: 'GPT-5.5',
|
||||||
|
description: 'OpenAI GPT-5.5 via GitHub Copilot',
|
||||||
|
extendedContext: true,
|
||||||
presetMapping: {
|
presetMapping: {
|
||||||
default: 'gpt-5.2',
|
default: 'gpt-5.5',
|
||||||
opus: 'gpt-5.2',
|
opus: 'gpt-5.5',
|
||||||
sonnet: 'gpt-5.1',
|
sonnet: 'gpt-5.4',
|
||||||
haiku: 'gpt-5-mini',
|
haiku: 'gpt-5.4-mini',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gpt-5.1',
|
id: 'gpt-5.4',
|
||||||
name: 'GPT-5.1',
|
name: 'GPT-5.4',
|
||||||
description: 'OpenAI GPT-5.1 via GitHub Copilot',
|
description: 'OpenAI GPT-5.4 via GitHub Copilot',
|
||||||
|
extendedContext: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gpt-5',
|
id: 'gpt-5.4-mini',
|
||||||
name: 'GPT-5',
|
name: 'GPT-5.4 Mini',
|
||||||
description: 'OpenAI GPT-5 via GitHub Copilot',
|
description: 'OpenAI GPT-5.4 Mini via GitHub Copilot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpt-5.4-nano',
|
||||||
|
name: 'GPT-5.4 Nano',
|
||||||
|
description: 'OpenAI GPT-5.4 Nano via GitHub Copilot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpt-5.3-codex',
|
||||||
|
name: 'GPT-5.3 Codex',
|
||||||
|
description: 'OpenAI GPT-5.3 Codex via GitHub Copilot',
|
||||||
|
extendedContext: true,
|
||||||
|
presetMapping: {
|
||||||
|
default: 'gpt-5.3-codex',
|
||||||
|
opus: 'gpt-5.3-codex',
|
||||||
|
sonnet: 'gpt-5.3-codex',
|
||||||
|
haiku: 'gpt-5-mini',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gpt-5-mini',
|
id: 'gpt-5-mini',
|
||||||
@@ -741,9 +815,39 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
|||||||
description: 'OpenAI GPT-5 Mini via GitHub Copilot',
|
description: 'OpenAI GPT-5 Mini via GitHub Copilot',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-3-pro',
|
id: 'gemini-2.5-pro',
|
||||||
name: 'Gemini 3 Pro',
|
name: 'Gemini 2.5 Pro',
|
||||||
description: 'Google Gemini 3 Pro via GitHub Copilot',
|
description: 'Google Gemini 2.5 Pro via GitHub Copilot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-3-flash',
|
||||||
|
name: 'Gemini 3 Flash',
|
||||||
|
description: 'Google Gemini 3 Flash via GitHub Copilot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-3.1-pro',
|
||||||
|
name: 'Gemini 3.1 Pro',
|
||||||
|
description: 'Google Gemini 3.1 Pro via GitHub Copilot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gemini-3.5-flash',
|
||||||
|
name: 'Gemini 3.5 Flash',
|
||||||
|
description: 'Google Gemini 3.5 Flash via GitHub Copilot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kimi-k2.7-code',
|
||||||
|
name: 'Kimi K2.7 Code',
|
||||||
|
description: 'Moonshot Kimi K2.7 Code via GitHub Copilot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mai-code-1-flash',
|
||||||
|
name: 'MAI-Code-1-Flash',
|
||||||
|
description: 'Microsoft MAI-Code-1-Flash via GitHub Copilot',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'raptor-mini',
|
||||||
|
name: 'Raptor Mini',
|
||||||
|
description: 'Microsoft Raptor Mini via GitHub Copilot',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -88,6 +88,47 @@ describe('FlexibleModelSelector', () => {
|
|||||||
expect(screen.getByRole('option', { name: /claude-sonnet-4-6/i })).toBeInTheDocument();
|
expect(screen.getByRole('option', { name: /claude-sonnet-4-6/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('surfaces Claude Sonnet 5 in the GitHub Copilot CLIProxy picker', async () => {
|
||||||
|
render(
|
||||||
|
<FlexibleModelSelector
|
||||||
|
label="Sonnet model"
|
||||||
|
value={undefined}
|
||||||
|
onChange={vi.fn()}
|
||||||
|
catalog={MODEL_CATALOGS.ghcp}
|
||||||
|
allModels={[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
|
||||||
|
await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'sonnet');
|
||||||
|
|
||||||
|
expect(screen.getByRole('option', { name: /claude-sonnet-5/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('option', { name: /claude-sonnet-4\.6/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Custom model')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets users enter a future GitHub Copilot model ID from the CLIProxy picker', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FlexibleModelSelector
|
||||||
|
label="Sonnet model"
|
||||||
|
value={undefined}
|
||||||
|
onChange={onChange}
|
||||||
|
catalog={MODEL_CATALOGS.ghcp}
|
||||||
|
allModels={[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
|
||||||
|
await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'claude-sonnet-6');
|
||||||
|
|
||||||
|
expect(screen.getAllByText('Custom model').length).toBeGreaterThan(0);
|
||||||
|
await userEvent.click(screen.getByRole('option', { name: /claude-sonnet-6/i }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith('claude-sonnet-6');
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves a filtered legacy value under the current-value fallback group', async () => {
|
it('preserves a filtered legacy value under the current-value fallback group', async () => {
|
||||||
render(
|
render(
|
||||||
<FlexibleModelSelector
|
<FlexibleModelSelector
|
||||||
|
|||||||
@@ -42,6 +42,42 @@ function SearchableSelectHarness() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SearchableSelectWithCreateHarness() {
|
||||||
|
const [value, setValue] = useState<string | undefined>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SearchableSelect
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
placeholder="Select model"
|
||||||
|
searchPlaceholder="Search models..."
|
||||||
|
emptyText="No results found."
|
||||||
|
groups={[
|
||||||
|
{ key: 'known', label: 'Known Models' },
|
||||||
|
{ key: 'custom', label: 'Custom Model' },
|
||||||
|
]}
|
||||||
|
createOption={(query) =>
|
||||||
|
/^[a-z0-9][a-z0-9.-]*$/i.test(query)
|
||||||
|
? {
|
||||||
|
value: query,
|
||||||
|
groupKey: 'custom',
|
||||||
|
searchText: query,
|
||||||
|
itemContent: <span>Use custom model {query}</span>,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'claude-sonnet-4',
|
||||||
|
groupKey: 'known',
|
||||||
|
searchText: 'Claude Sonnet 4 claude-sonnet-4',
|
||||||
|
itemContent: <span>Claude Sonnet 4</span>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
describe('SearchableSelect', () => {
|
describe('SearchableSelect', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
|
Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
|
||||||
@@ -94,6 +130,22 @@ describe('SearchableSelect', () => {
|
|||||||
expect(screen.getByText('No results found.')).toBeInTheDocument();
|
expect(screen.getByText('No results found.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can append and select a custom option from the search query', async () => {
|
||||||
|
render(<SearchableSelectWithCreateHarness />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Select model' }));
|
||||||
|
await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'claude-sonnet-5');
|
||||||
|
|
||||||
|
expect(screen.getByText('Custom Model')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('option', { name: 'Use custom model claude-sonnet-5' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Use custom model claude-sonnet-5' })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('supports keyboard navigation and selection from the search input', async () => {
|
it('supports keyboard navigation and selection from the search input', async () => {
|
||||||
render(<SearchableSelectHarness />);
|
render(<SearchableSelectHarness />);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||||
|
|
||||||
|
describe('GitHub Copilot model catalog defaults', () => {
|
||||||
|
it('surfaces Claude Sonnet 5 as the default Copilot preset model', () => {
|
||||||
|
const ghcpCatalog = MODEL_CATALOGS.ghcp;
|
||||||
|
const sonnet5 = ghcpCatalog.models.find((model) => model.id === 'claude-sonnet-5');
|
||||||
|
|
||||||
|
expect(ghcpCatalog.defaultModel).toBe('claude-sonnet-5');
|
||||||
|
expect(sonnet5?.name).toBe('Claude Sonnet 5');
|
||||||
|
expect(sonnet5?.extendedContext).toBe(true);
|
||||||
|
expect(sonnet5?.presetMapping).toEqual({
|
||||||
|
default: 'claude-sonnet-5',
|
||||||
|
opus: 'claude-opus-4.8',
|
||||||
|
sonnet: 'claude-sonnet-5',
|
||||||
|
haiku: 'claude-haiku-4.5',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advertises current Copilot models without retired recommendations', () => {
|
||||||
|
const ids = MODEL_CATALOGS.ghcp.models.map((model) => model.id);
|
||||||
|
|
||||||
|
expect(ids).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'claude-sonnet-5',
|
||||||
|
'claude-opus-4.8',
|
||||||
|
'claude-opus-4.8-fast-mode',
|
||||||
|
'claude-fable-5',
|
||||||
|
'gpt-5.5',
|
||||||
|
'gpt-5.4',
|
||||||
|
'gpt-5.3-codex',
|
||||||
|
'gemini-3.5-flash',
|
||||||
|
'kimi-k2.7-code',
|
||||||
|
'mai-code-1-flash',
|
||||||
|
'raptor-mini',
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(ids).not.toEqual(
|
||||||
|
expect.arrayContaining(['claude-sonnet-4', 'gpt-5.2', 'gpt-5.1', 'gpt-5', 'gemini-3-pro'])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user