Compare commits

...

1 Commits

Author SHA1 Message Date
Jamison Lahman
0c709a4fb6 nit 2026-04-09 08:20:13 -07:00
3 changed files with 282 additions and 9 deletions

View File

@@ -47,6 +47,8 @@ from onyx.llm.factory import get_llm
from onyx.llm.factory import get_max_input_tokens_from_llm_provider
from onyx.llm.utils import get_bedrock_token_limit
from onyx.llm.utils import get_llm_contextual_cost
from onyx.llm.utils import get_max_input_tokens
from onyx.llm.utils import litellm_thinks_model_supports_image_input
from onyx.llm.utils import test_llm
from onyx.llm.well_known_providers.auto_update_service import (
fetch_llm_recommendations_from_github,
@@ -62,6 +64,8 @@ from onyx.server.manage.llm.models import BedrockFinalModelResponse
from onyx.server.manage.llm.models import BedrockModelsRequest
from onyx.server.manage.llm.models import BifrostFinalModelResponse
from onyx.server.manage.llm.models import BifrostModelsRequest
from onyx.server.manage.llm.models import CustomProviderModelResponse
from onyx.server.manage.llm.models import CustomProviderModelsRequest
from onyx.server.manage.llm.models import CustomProviderOption
from onyx.server.manage.llm.models import DefaultModel
from onyx.server.manage.llm.models import LitellmFinalModelResponse
@@ -276,6 +280,158 @@ def fetch_custom_provider_names(
)
@admin_router.post("/custom/available-models")
def fetch_custom_provider_models(
request: CustomProviderModelsRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> list[CustomProviderModelResponse]:
"""Fetch models for a custom provider.
When ``api_base`` is provided the endpoint hits the provider's
OpenAI-compatible ``/v1/models`` (or ``/{api_version}/models``) to
discover live models. Otherwise it falls back to the static list
that LiteLLM ships for the given provider slug.
In both cases the response is enriched with metadata from LiteLLM
(display name, max input tokens, vision support) when available.
"""
if request.api_base:
return _fetch_custom_models_from_api(
provider=request.provider,
api_base=request.api_base,
api_key=request.api_key,
api_version=request.api_version,
)
return _fetch_custom_models_from_litellm(request.provider)
def _enrich_custom_model(
name: str,
provider: str,
*,
api_display_name: str | None = None,
api_max_input_tokens: int | None = None,
api_supports_image_input: bool | None = None,
) -> CustomProviderModelResponse:
"""Build a ``CustomProviderModelResponse`` enriched with LiteLLM metadata.
Values explicitly provided by the source API take precedence; LiteLLM
metadata is used as a fallback.
"""
from onyx.llm.model_name_parser import parse_litellm_model_name
# LiteLLM keys are typically "provider/model"
litellm_key = f"{provider}/{name}" if not name.startswith(f"{provider}/") else name
parsed = parse_litellm_model_name(litellm_key)
# display_name: prefer API-provided name, then LiteLLM enrichment, then raw name
if api_display_name and api_display_name != name:
display_name = api_display_name
else:
display_name = parsed.display_name or name
# max_input_tokens: prefer API value, then LiteLLM lookup
if api_max_input_tokens is not None:
max_input_tokens: int | None = api_max_input_tokens
else:
try:
max_input_tokens = get_max_input_tokens(name, provider)
except Exception:
max_input_tokens = None
# supports_image_input: prefer API value, then LiteLLM inference
if api_supports_image_input is not None:
supports_image = api_supports_image_input
else:
supports_image = litellm_thinks_model_supports_image_input(name, provider)
return CustomProviderModelResponse(
name=name,
display_name=display_name,
max_input_tokens=max_input_tokens,
supports_image_input=supports_image,
)
def _fetch_custom_models_from_api(
provider: str,
api_base: str,
api_key: str | None,
api_version: str | None,
) -> list[CustomProviderModelResponse]:
"""Hit an OpenAI-compatible ``/v1/models`` (or versioned variant)."""
cleaned = api_base.strip().rstrip("/")
if api_version:
url = f"{cleaned}/{api_version.strip().strip('/')}/models"
elif cleaned.endswith("/v1"):
url = f"{cleaned}/models"
else:
url = f"{cleaned}/v1/models"
response_json = _get_openai_compatible_models_response(
url=url,
source_name="Custom provider",
api_key=api_key,
)
models = response_json.get("data", [])
if not isinstance(models, list) or len(models) == 0:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"No models found from the provider's API.",
)
results: list[CustomProviderModelResponse] = []
for model in models:
try:
model_id = model.get("id", "")
if not model_id:
continue
if is_embedding_model(model_id):
continue
results.append(
_enrich_custom_model(
model_id,
provider,
api_display_name=model.get("name"),
api_max_input_tokens=model.get("context_length"),
api_supports_image_input=infer_vision_support(model_id),
)
)
except Exception as e:
logger.warning(
"Failed to parse custom provider model entry",
extra={"error": str(e), "item": str(model)[:1000]},
)
if not results:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"No compatible models found from the provider's API.",
)
return sorted(results, key=lambda m: m.name.lower())
def _fetch_custom_models_from_litellm(
provider: str,
) -> list[CustomProviderModelResponse]:
"""Fall back to litellm's static ``models_by_provider`` mapping."""
import litellm
model_names = litellm.models_by_provider.get(provider)
if model_names is None:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
f"Unknown provider: {provider}",
)
return sorted(
(_enrich_custom_model(name, provider) for name in model_names),
key=lambda m: m.name.lower(),
)
@admin_router.get("/built-in/options")
def fetch_llm_options(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),

View File

@@ -477,6 +477,21 @@ class BifrostFinalModelResponse(BaseModel):
supports_reasoning: bool
# Custom provider dynamic models fetch
class CustomProviderModelsRequest(BaseModel):
provider: str # LiteLLM provider slug (e.g. "deepseek", "fireworks_ai")
api_base: str | None = None # If set, fetches live models via /v1/models
api_key: str | None = None
api_version: str | None = None # If set, used to construct the models URL
class CustomProviderModelResponse(BaseModel):
name: str
display_name: str
max_input_tokens: int | None
supports_image_input: bool
# OpenAI Compatible dynamic models fetch
class OpenAICompatibleModelsRequest(BaseModel):
api_base: str

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSWRConfig } from "swr";
import { useFormikContext } from "formik";
import {
@@ -29,8 +29,9 @@ import InputComboBox from "@/refresh-components/inputs/InputComboBox";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import Text from "@/refresh-components/texts/Text";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { Button, Card, EmptyMessageCard } from "@opal/components";
import { SvgMinusCircle, SvgPlusCircle } from "@opal/icons";
import { SvgMinusCircle, SvgPlusCircle, SvgRefreshCw } from "@opal/icons";
import { markdown } from "@opal/utils";
import { toast } from "@/hooks/useToast";
import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
@@ -110,6 +111,95 @@ function ModelConfigurationItem({
);
}
interface FetchedModel {
name: string;
display_name: string;
max_input_tokens: number | null;
supports_image_input: boolean;
}
function FetchModelsButton({ provider }: { provider: string }) {
const abortRef = useRef<AbortController | null>(null);
const [isFetching, setIsFetching] = useState(false);
const formikProps = useFormikContext<{
api_base?: string;
api_key?: string;
api_version?: string;
model_configurations: CustomModelConfiguration[];
}>();
useEffect(() => {
return () => abortRef.current?.abort();
}, []);
async function handleFetch() {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setIsFetching(true);
try {
const response = await fetch("/api/admin/llm/custom/available-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider,
api_base: formikProps.values.api_base || undefined,
api_key: formikProps.values.api_key || undefined,
api_version: formikProps.values.api_version || undefined,
}),
signal: controller.signal,
});
if (!response.ok) {
let errorMessage = "Failed to fetch models";
try {
const errorData = await response.json();
errorMessage = errorData.detail || errorMessage;
} catch {
// ignore JSON parsing errors
}
throw new Error(errorMessage);
}
const fetched: FetchedModel[] = await response.json();
const existing = formikProps.values.model_configurations;
const existingNames = new Set(existing.map((m) => m.name));
const newModels: CustomModelConfiguration[] = fetched
.filter((m) => !existingNames.has(m.name))
.map((m) => ({
name: m.name,
display_name: m.display_name !== m.name ? m.display_name : "",
max_input_tokens: m.max_input_tokens,
supports_image_input: m.supports_image_input,
}));
// Replace empty placeholder rows, then merge
const nonEmpty = existing.filter((m) => m.name.trim() !== "");
formikProps.setFieldValue("model_configurations", [
...nonEmpty,
...newModels,
]);
toast.success(`Fetched ${fetched.length} models`);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
toast.error(
err instanceof Error ? err.message : "Failed to fetch models"
);
} finally {
if (!controller.signal.aborted) {
setIsFetching(false);
}
}
}
return (
<Button
prominence="tertiary"
icon={isFetching ? SimpleLoader : SvgRefreshCw}
onClick={handleFetch}
disabled={isFetching || !provider}
type="button"
/>
);
}
function ModelConfigurationList() {
const formikProps = useFormikContext<{
model_configurations: CustomModelConfiguration[];
@@ -222,6 +312,24 @@ function ProviderNameSelect({ disabled }: { disabled?: boolean }) {
);
}
function ModelsHeader() {
const { values } = useFormikContext<{ provider: string }>();
return (
<InputLayouts.Horizontal
title="Models"
description="List LLM models you wish to use and their configurations for this provider. See full list of models at LiteLLM."
nonInteractive
center
>
{values.provider ? (
<FetchModelsButton provider={values.provider} />
) : (
<div />
)}
</InputLayouts.Horizontal>
);
}
// ─── Custom Config Processing ─────────────────────────────────────────────────
function keyValueListToDict(items: KeyValue[]): Record<string, string> {
@@ -424,13 +532,7 @@ export default function CustomModal({
<InputLayouts.FieldSeparator />
<Section gap={0.5}>
<InputLayouts.FieldPadder>
<Content
title="Models"
description="List LLM models you wish to use and their configurations for this provider. See full list of models at LiteLLM."
variant="section"
sizePreset="main-content"
widthVariant="full"
/>
<ModelsHeader />
</InputLayouts.FieldPadder>
<Card padding="sm">