feat: support custom GHCP model selection

This commit is contained in:
Tam Nhu Tran committed 2026-07-02 10:10:28 -04:00
1 parent 1aacbfd2a8
commit 631a515986
7 files changed
+324 -26

No files matched your search

@@ -72,6 +72,17 @@ interface ProviderModelSelectorProps {
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 }) {
return (
<Badge variant="outline" className="text-[9px] h-4 px-1">
@@ -396,6 +407,25 @@ export function FlexibleModelSelector({
() => routingHints.get(normalizeModelValue(value, routing).toLowerCase()),
[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 routingHint = routingHints.get(model.id.toLowerCase());
@@ -544,6 +574,7 @@ export function FlexibleModelSelector({
: t('providerModelSelector.noModelsAvailable')
}
triggerClassName="h-9"
createOption={createCustomModelOption}
groups={[
...(selectedValueMissing && legacySelectedOption
? [
@@ -597,6 +628,14 @@ export function FlexibleModelSelector({
},
]
: []),
{
key: 'custom',
label: (
<span className="text-xs text-muted-foreground">
{t('providerModelSelector.customModel')}
</span>
),
},
]}
options={[
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
+18 -4
View File
@@ -25,6 +25,7 @@ interface SearchableSelectProps {
value?: string;
onChange: (value: string) => void;
options: SearchableSelectOption[];
createOption?: (query: string) => SearchableSelectOption | null | undefined;
groups?: SearchableSelectGroup[];
placeholder: string;
searchPlaceholder: string;
@@ -47,6 +48,7 @@ export function SearchableSelect({
value,
onChange,
options,
createOption,
groups,
placeholder,
searchPlaceholder,
@@ -64,20 +66,32 @@ export function SearchableSelect({
const optionRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});
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 normalizedQuery = normalizeSearch(query);
if (!normalizedQuery) return options;
return options.filter((option) =>
const matchedOptions = options.filter((option) =>
[option.searchText, ...(option.keywords ?? [])].some((candidate) =>
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 knownGroups = new Map((groups ?? []).map((group) => [group.key, group]));
+5
View File
@@ -221,6 +221,7 @@ const resources = {
prefixOnly: 'Prefix only',
current: 'Current',
currentValue: 'Current value',
customModel: 'Custom model',
preferredPinnedModel: 'Preferred pinned model:',
pinnedRouteStatus: 'Pinned route status:',
pinnedModelNotAdvertised:
@@ -2947,6 +2948,7 @@ const resources = {
prefixOnly: '仅前缀',
current: '当前',
currentValue: '当前值',
customModel: '自定义模型',
preferredPinnedModel: '偏好的固定模型:',
pinnedRouteStatus: '固定路由状态:',
pinnedModelNotAdvertised: '固定模型当前未被代理广播:{{model}}',
@@ -5555,6 +5557,7 @@ const resources = {
prefixOnly: 'Chỉ tiền tố',
current: '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:',
pinnedRouteStatus: 'Trạng thái tuyến ghim:',
pinnedModelNotAdvertised: 'Mô hình ghim hiện không được proxy quảng bá: {{model}}',
@@ -8279,6 +8282,7 @@ const resources = {
prefixOnly: 'プレフィックスのみ',
current: '現在',
currentValue: '現在の値',
customModel: 'カスタムモデル',
preferredPinnedModel: '優先ピンモデル:',
pinnedRouteStatus: 'ピンルートの状態:',
pinnedModelNotAdvertised: 'ピンモデルは現在プロキシから通知されていません: {{model}}',
@@ -11026,6 +11030,7 @@ const resources = {
prefixOnly: '접두사 전용',
current: '현재',
currentValue: '현재 값',
customModel: '사용자 지정 모델',
preferredPinnedModel: '선호하는 고정 모델:',
pinnedRouteStatus: '고정된 라우트 상태:',
pinnedModelNotAdvertised: '고정된 모델이 현재 프록시에서 알리지 않고 있습니다: {{model}}',
+126 -22
View File
@@ -680,8 +680,50 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
ghcp: {
provider: 'ghcp',
displayName: 'GitHub Copilot (OAuth)',
defaultModel: 'claude-sonnet-4.5',
defaultModel: 'claude-sonnet-5',
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',
name: 'Claude Opus 4.5',
@@ -693,6 +735,18 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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',
name: 'Claude Sonnet 4.5',
@@ -704,36 +758,56 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
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',
name: 'Claude Haiku 4.5',
description: 'Anthropic Claude Haiku 4.5 via GitHub Copilot',
},
{
id: 'gpt-5.2',
name: 'GPT-5.2',
description: 'OpenAI GPT-5.2 via GitHub Copilot',
id: 'claude-fable-5',
name: 'Claude Fable 5',
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: {
default: 'gpt-5.2',
opus: 'gpt-5.2',
sonnet: 'gpt-5.1',
haiku: 'gpt-5-mini',
default: 'gpt-5.5',
opus: 'gpt-5.5',
sonnet: 'gpt-5.4',
haiku: 'gpt-5.4-mini',
},
},
{
id: 'gpt-5.1',
name: 'GPT-5.1',
description: 'OpenAI GPT-5.1 via GitHub Copilot',
id: 'gpt-5.4',
name: 'GPT-5.4',
description: 'OpenAI GPT-5.4 via GitHub Copilot',
extendedContext: true,
},
{
id: 'gpt-5',
name: 'GPT-5',
description: 'OpenAI GPT-5 via GitHub Copilot',
id: 'gpt-5.4-mini',
name: 'GPT-5.4 Mini',
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',
@@ -741,9 +815,39 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
description: 'OpenAI GPT-5 Mini via GitHub Copilot',
},
{
id: 'gemini-3-pro',
name: 'Gemini 3 Pro',
description: 'Google Gemini 3 Pro via GitHub Copilot',
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
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();
});
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 () => {
render(
<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', () => {
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
@@ -94,6 +130,22 @@ describe('SearchableSelect', () => {
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 () => {
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'])
);
});
});