mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-05 10:13:12 +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;
|
||||
}
|
||||
|
||||
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] : []),
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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
@@ -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'])
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user