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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user