feat: auto-detect models from custom providers

Add 'Detect Models' button to custom provider forms that calls the
provider's /models endpoint to discover available models automatically.

- Supports OpenAI-compatible, Anthropic-compatible, and Google
  Generative AI providers
- Auto-fills context window and max tokens from provider response
- Filters out embedding, reranking, and non-text models
- Removes empty default model rows after detection
- Added comprehensive backend and frontend test coverage
This commit is contained in:
Islam Nofl
2026-05-13 14:13:26 +03:00
parent b4b80ac0cb
commit d57968ae91
7 changed files with 909 additions and 6 deletions

View File

@@ -3,10 +3,11 @@ import {
addCustomProvider,
deleteCustomProvider,
fetchCustomProviders,
probeProviderModels,
updateCustomProvider,
type CustomProvider,
} from "../api";
import { AlertCircle, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
import { AlertCircle, Loader2, Pencil, Plus, Search, Trash2 } from "lucide-react";
import { OnboardingDisclosure } from "./OnboardingDisclosure";
import "./CustomProvidersSection.css";
@@ -74,6 +75,8 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
const [models, setModels] = useState("");
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [detecting, setDetecting] = useState(false);
const [detectError, setDetectError] = useState<string | null>(null);
const loadProviders = useCallback(async () => {
setLoading(true);
@@ -112,6 +115,8 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
setApiKey("");
setModels("");
setFormError(null);
setDetectError(null);
setDetecting(false);
setIsFormOpen(false);
}, []);
@@ -123,6 +128,8 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
setApiKey("");
setModels("");
setFormError(null);
setDetectError(null);
setDetecting(false);
setIsFormOpen(true);
}, []);
@@ -134,6 +141,8 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
setApiKey(provider.apiKey ?? "");
setModels((provider.models ?? []).map((model) => model.id).join(", "));
setFormError(null);
setDetectError(null);
setDetecting(false);
setIsFormOpen(true);
}, []);
@@ -165,6 +174,45 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
return null;
}, [apiType, baseUrl, name]);
// Detect Models is available for all API types that expose a /models endpoint
const handleDetectModels = useCallback(async () => {
const trimmedBaseUrl = baseUrl.trim();
if (!trimmedBaseUrl) {
setDetectError("Base URL is required to detect models.");
return;
}
setDetecting(true);
setDetectError(null);
try {
const result = await probeProviderModels({
baseUrl: trimmedBaseUrl,
apiKey: apiKey.trim() || undefined,
apiType,
});
if (result.models.length > 0) {
const discoveredIds = result.models.map((m) => m.id).join(", ");
setModels((prev) => {
const existing = prev.trim();
if (existing) {
return discoveredIds + ", " + existing;
}
return discoveredIds;
});
} else {
setDetectError("No models found. The provider may require an API key.");
}
} catch (err) {
setDetectError(
err instanceof Error ? err.message : "Failed to detect models",
);
} finally {
setDetecting(false);
}
}, [baseUrl, apiKey, apiType]);
const handleSave = useCallback(async () => {
const validationError = validateForm();
setFormError(validationError);
@@ -327,6 +375,27 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
/>
</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center", marginTop: "4px" }}>
<button
type="button"
className="btn btn-sm"
onClick={() => void handleDetectModels()}
disabled={saving || detecting || !baseUrl.trim()}
title="Auto-detect models from the provider's /models endpoint"
>
{detecting ? (
<>
<Loader2 className="spin" size={14} /> Detecting
</>
) : (
<>
<Search size={14} /> Detect Models
</>
)}
</button>
</div>
{detectError ? <div className="custom-provider-form-error">{detectError}</div> : null}
{formError ? <div className="custom-provider-form-error">{formError}</div> : null}
<div className="custom-provider-form-actions">
@@ -421,6 +490,27 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C
/>
</div>
<div style={{ display: "flex", gap: "8px", alignItems: "center", marginTop: "4px" }}>
<button
type="button"
className="btn btn-sm"
onClick={() => void handleDetectModels()}
disabled={saving || detecting || !baseUrl.trim()}
title="Auto-detect models from the provider's /models endpoint"
>
{detecting ? (
<>
<Loader2 className="spin" size={14} /> Detecting
</>
) : (
<>
<Search size={14} /> Detect Models
</>
)}
</button>
</div>
{detectError ? <div className="custom-provider-form-error">{detectError}</div> : null}
{formError ? <div className="custom-provider-form-error">{formError}</div> : null}
<div className="custom-provider-form-actions">