Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 70 additions & 9 deletions frontend/src/components/app-config/ai-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import { IncorrectModelId } from "./incorrect-model-id";
import { IsOverridden } from "./is-overridden";
import { MCPConfig } from "./mcp-config";
import { aiSettingsSubTabAtom } from "./state";
import { useProviderModels } from "./use-provider-models";

interface AiConfigProps {
form: UseFormReturn<UserConfig>;
Expand Down Expand Up @@ -1533,6 +1534,7 @@ export const AiModelDisplayConfig: React.FC<AiConfigProps> = ({
form={form}
customModels={customModels}
customProviderNames={customProviderNames}
customProviders={customProviders || {}}
onSubmit={onSubmit}
/>
</SettingGroup>
Expand All @@ -1543,13 +1545,22 @@ export const AddModelForm: React.FC<{
form: UseFormReturn<UserConfig>;
customModels: QualifiedModelId[];
customProviderNames: string[];
customProviders: Record<string, CustomProviderConfig>;
onSubmit: (values: UserConfig) => void;
}> = ({ form, customModels, customProviderNames, onSubmit }) => {
}> = ({
form,
customModels,
customProviderNames,
customProviders,
onSubmit,
}) => {
const [isFormOpen, setIsFormOpen] = useState(false);
const [modelAdded, setModelAdded] = useState(false);
const [provider, setProvider] = useState<ProviderId | "custom" | null>(null);
const [customProviderName, setCustomProviderName] = useState("");
const [modelName, setModelName] = useState("");
const [modelSelectedFromDropdown, setModelSelectedFromDropdown] =
useState(false);

const providerSelectId = useId();
const customProviderInputId = useId();
Expand All @@ -1559,6 +1570,19 @@ export const AddModelForm: React.FC<{
const providerName = isCustomProvider ? customProviderName : provider;
const hasValidValues = providerName?.trim() && modelName?.trim();

// Resolve base URL fΓΌr den gewΓ€hlten Provider
const resolvedProviderName = isCustomProvider ? customProviderName : provider;
const ollamaBaseUrl = form.getValues("ai.ollama.base_url");
const resolvedBaseUrl = resolvedProviderName
? (customProviders[resolvedProviderName]?.base_url ??
(resolvedProviderName === "ollama"
? ollamaBaseUrl || "http://localhost:11434"
: undefined))
: undefined;

const { models: availableModels, isLoading: isLoadingModels } =
useProviderModels(resolvedBaseUrl);

const resetForm = () => {
setProvider(null);
setCustomProviderName("");
Expand Down Expand Up @@ -1601,7 +1625,13 @@ export const AddModelForm: React.FC<{
</Label>
<Select
value={provider || ""}
onValueChange={(v) => setProvider(v as ProviderId | "custom")}
onValueChange={(v) => {
setProvider(v as ProviderId | "custom");
if (modelSelectedFromDropdown) {
setModelName("");
setModelSelectedFromDropdown(false);
}
}}
>
<SelectTrigger id={providerSelectId} className={providerClassName}>
{provider ? (
Expand Down Expand Up @@ -1696,13 +1726,44 @@ export const AddModelForm: React.FC<{
>
Model
</Label>
<Input
id={modelNameInputId}
value={modelName}
onChange={(e) => setModelName(e.target.value)}
placeholder="gpt-4"
className="text-xs mb-0"
/>
{availableModels.length > 0 ? (
<Select
value={modelName}
onValueChange={(v) => {
setModelName(v);
setModelSelectedFromDropdown(true);
}}
>
<SelectTrigger id={modelNameInputId} className="w-48 text-xs">
{isLoadingModels ? (
<span className="text-muted-foreground">Loading...</span>
) : (
<span>{modelName || "Select model..."}</span>
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{availableModels.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<Input
id={modelNameInputId}
value={modelName}
onChange={(e) => {
setModelName(e.target.value);
setModelSelectedFromDropdown(false);
}}
placeholder={isLoadingModels ? "Loading models..." : "gpt-4"}
disabled={isLoadingModels}
className="text-xs mb-0"
/>
)}
</div>
);

Expand Down
63 changes: 63 additions & 0 deletions frontend/src/components/app-config/use-provider-models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/* Copyright 2026 Marimo. All rights reserved. */
import { useState, useEffect } from "react";

export interface ProviderModelsResult {
models: string[];
isLoading: boolean;
error: string | null;
}

/**
* Fetches available models from an OpenAI-compatible endpoint
* via the Marimo backend proxy to avoid CORS issues.
*/
export function useProviderModels(
baseUrl: string | null | undefined,
): ProviderModelsResult {
const [models, setModels] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (!baseUrl) {
setModels([]);
setError(null);
return;
}

const controller = new AbortController();

const fetchModels = async () => {
setIsLoading(true);
setError(null);
Comment on lines +21 to +32

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider using useAsyncData, it's a similar pattern


Comment on lines +30 to +33
Comment on lines +30 to +33
try {
const params = new URLSearchParams({ base_url: baseUrl });
const response = await fetch(`/api/ai/models?${params}`, {
signal: controller.signal,
});
Comment on lines +35 to +38
Comment on lines +35 to +38

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

const data = (await response.json()) as { models: string[] };
setModels(data.models ?? []);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return;
}
setError("Could not fetch models from endpoint");
setModels([]);
} finally {
setIsLoading(false);
}
Comment on lines +52 to +54
Comment on lines +52 to +54
};

fetchModels();

return () => controller.abort();
}, [baseUrl]);

return { models, isLoading, error };
}
63 changes: 63 additions & 0 deletions marimo/_server/api/endpoints/ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from typing import TYPE_CHECKING, Literal

import httpx
from pydantic import BaseModel

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users may not have pydantic. We should not require this as an import, only as TYPE_CHECKING import if needed.

from starlette.authentication import requires
from starlette.exceptions import HTTPException
from starlette.responses import (
Expand Down Expand Up @@ -69,6 +71,10 @@
router = APIRouter()


class ProviderModelsResponse(BaseModel):
models: list[str]


async def safe_stream_wrapper(
stream_generator: AsyncGenerator[str, None],
text_only: bool,
Expand Down Expand Up @@ -571,3 +577,60 @@
servers={},
)
)


@router.get("/models")
@requires("edit")
async def get_provider_models(
*,
request: Request,
) -> Response:
"""
parameters:
- in: query
name: base_url
schema:
type: string
required: true
description: The base URL of the OpenAI-compatible provider
responses:
200:
description: List of available models from the provider
content:
application/json:
schema:
type: object
properties:
models:
type: array
items:
type: string
"""

base_url = request.query_params.get("base_url", "").rstrip("/")
if not base_url:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail="base_url query parameter is required",
)

Comment on lines +582 to +616
Comment on lines +582 to +616
# Normalize URL
if base_url.endswith("/v1"):
models_url = f"{base_url}/models"
else:
models_url = f"{base_url}/v1/models"

try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(models_url)
Comment thread
Light2Dark marked this conversation as resolved.
Comment thread
Light2Dark marked this conversation as resolved.
response.raise_for_status()
data = response.json()
model_ids = sorted(
[m["id"] for m in data.get("data", []) if m.get("id")]
)
return StructResponse(ProviderModelsResponse(models=model_ids))

Check failure on line 631 in marimo/_server/api/endpoints/ai.py

View workflow job for this annotation

GitHub Actions / Test lint and typecheck

Argument 1 to "StructResponse" has incompatible type "ProviderModelsResponse"; expected "Struct" [arg-type]
Comment on lines +610 to +631
Comment on lines +610 to +631
except Exception as e:
LOGGER.warning(
"Could not fetch models from %s: %s", models_url, str(e)
)
return StructResponse(ProviderModelsResponse(models=[]))

Check failure on line 636 in marimo/_server/api/endpoints/ai.py

View workflow job for this annotation

GitHub Actions / Test lint and typecheck

Argument 1 to "StructResponse" has incompatible type "ProviderModelsResponse"; expected "Struct" [arg-type]
Comment on lines +623 to +636
Comment on lines +623 to +636
21 changes: 21 additions & 0 deletions packages/openapi/api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5778,6 +5778,27 @@ paths:
schema:
$ref: '#/components/schemas/MCPStatusResponse'
description: Get MCP server status
/api/ai/models:
get:
parameters:
- description: The base URL of the OpenAI-compatible provider
in: query
name: base_url
required: true
schema:
type: string
responses:
200:
content:
application/json:
schema:
properties:
models:
items:
type: string
type: array
type: object
description: List of available models from the provider
/api/cache/clear:
post:
parameters:
Expand Down
40 changes: 40 additions & 0 deletions packages/openapi/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,46 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/ai/models": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query: {
/** @description The base URL of the OpenAI-compatible provider */
base_url: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description List of available models from the provider */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
models?: string[];
};
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/cache/clear": {
parameters: {
query?: never;
Expand Down
Loading