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]));