diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 350ed4d1..aa644703 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -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 ( @@ -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: {customModelId}, + itemContent: ( +
+ {customModelId} + + {t('providerModelSelector.customModel')} + +
+ ), + }; + }; 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: ( + + {t('providerModelSelector.customModel')} + + ), + }, ]} options={[ ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []), diff --git a/ui/src/components/ui/searchable-select.tsx b/ui/src/components/ui/searchable-select.tsx index b971bb0f..38a6f937 100644 --- a/ui/src/components/ui/searchable-select.tsx +++ b/ui/src/components/ui/searchable-select.tsx @@ -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>({}); 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])); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 50f097ef..39988ff4 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -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}}', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 4292774c..fa877db2 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -680,8 +680,50 @@ export const MODEL_CATALOGS: Record = { 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 = { 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 = { 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 = { 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', }, ], }, diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx index fd2e3bd0..0149a437 100644 --- a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( (); + + return ( + + /^[a-z0-9][a-z0-9.-]*$/i.test(query) + ? { + value: query, + groupKey: 'custom', + searchText: query, + itemContent: Use custom model {query}, + } + : null + } + options={[ + { + value: 'claude-sonnet-4', + groupKey: 'known', + searchText: 'Claude Sonnet 4 claude-sonnet-4', + itemContent: Claude Sonnet 4, + }, + ]} + /> + ); +} + 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(); + + 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(); diff --git a/ui/tests/unit/ui/lib/model-catalogs-ghcp.test.ts b/ui/tests/unit/ui/lib/model-catalogs-ghcp.test.ts new file mode 100644 index 00000000..96537eb8 --- /dev/null +++ b/ui/tests/unit/ui/lib/model-catalogs-ghcp.test.ts @@ -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']) + ); + }); +});