From a487a20bbc1f1485db18d6fef91d3b8deb65b507 Mon Sep 17 00:00:00 2001 From: Islam Nofl Date: Wed, 13 May 2026 14:13:26 +0300 Subject: [PATCH 1/4] 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 --- packages/dashboard/app/api/legacy.ts | 35 +++ .../app/components/CustomProviderForm.tsx | 94 +++++- .../app/components/CustomProvidersSection.tsx | 92 +++++- .../__tests__/CustomProviderForm.test.tsx | 187 +++++++++++- .../__tests__/CustomProvidersSection.test.tsx | 1 + .../routes/__tests__/custom-providers.test.ts | 278 ++++++++++++++++++ .../routes/register-custom-provider-routes.ts | 228 ++++++++++++++ 7 files changed, 909 insertions(+), 6 deletions(-) diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 28e2a71de..e9ecaf7a6 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -1868,6 +1868,41 @@ export function createCustomProvider(config: CustomProviderConfig): Promise { + return api("/custom-providers/probe-models", { + method: "POST", + body: JSON.stringify({ + baseUrl: params.baseUrl, + apiKey: params.apiKey, + apiType: params.apiType, + }), + }); +} + /** Fetch authentication status for all OAuth providers */ export function fetchAuthStatus(): Promise<{ providers: AuthProvider[]; diff --git a/packages/dashboard/app/components/CustomProviderForm.tsx b/packages/dashboard/app/components/CustomProviderForm.tsx index 121639c89..717c3f036 100644 --- a/packages/dashboard/app/components/CustomProviderForm.tsx +++ b/packages/dashboard/app/components/CustomProviderForm.tsx @@ -1,5 +1,7 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { Loader2, Search } from "lucide-react"; import type { CustomProviderConfig, CustomProviderModelInput } from "../api"; +import { probeProviderModels } from "../api"; import "./CustomProviderForm.css"; // Reserved built-in IDs (including hidden/deprecated aliases) to prevent custom-provider collisions. @@ -42,6 +44,8 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f const [apiKey, setApiKey] = useState(initialConfig?.apiKey ?? ""); const [models, setModels] = useState(initialConfig?.models?.length ? initialConfig.models : [emptyModel()]); const [validationError, setValidationError] = useState(null); + const [detecting, setDetecting] = useState(false); + const [detectError, setDetectError] = useState(null); const canRemoveModel = models.length > 1; @@ -55,6 +59,68 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f setModels((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))); } + // Detect Models is available for all API types that expose a /models endpoint: + // - openai-completions / openai-responses → openai-compatible + // - anthropic-messages → anthropic-compatible + // - google-generative-ai → google-generative-ai + const probeApiType = api === "anthropic-messages" + ? "anthropic-compatible" + : api === "google-generative-ai" + ? "google-generative-ai" + : "openai-compatible"; + + 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: probeApiType, + }); + + if (result.models.length === 0) { + setDetectError("No models found. The provider may require an API key."); + return; + } + + // Merge discovered models, avoiding duplicates by ID + const existingIds = new Set(models.map((m) => m.id.trim())); + const newModels = result.models + .filter((m) => !existingIds.has(m.id.trim())) + .map((m) => ({ + id: m.id, + name: m.name || m.id, + reasoning: Boolean(m.reasoning), + contextWindow: m.contextWindow, + maxTokens: m.maxTokens, + })); + + if (newModels.length > 0) { + // Replace empty default rows with discovered models + setModels((prev) => { + const nonEmpty = prev.filter((m) => m.id.trim().length > 0); + return [...nonEmpty, ...newModels]; + }); + } else { + setDetectError("All discovered models are already in the list."); + } + } catch (err) { + setDetectError( + err instanceof Error ? err.message : "Failed to detect models", + ); + } finally { + setDetecting(false); + } + }, [baseUrl, apiKey, probeApiType, models]); + function validate(): string | null { if (!id.trim()) return "Provider ID is required."; if (!PROVIDER_ID_PATTERN.test(id.trim())) return "Provider ID must be kebab-case."; @@ -187,9 +253,29 @@ export function CustomProviderForm({ initialConfig, onSave, onCancel, saving = f ))} - +
+ + +
+ {detectError ?
{detectError}
: null} {mergedError ?
{mergedError}
: null} diff --git a/packages/dashboard/app/components/CustomProvidersSection.tsx b/packages/dashboard/app/components/CustomProvidersSection.tsx index 19a0894c0..bdc3b89bb 100644 --- a/packages/dashboard/app/components/CustomProvidersSection.tsx +++ b/packages/dashboard/app/components/CustomProvidersSection.tsx @@ -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(null); + const [detecting, setDetecting] = useState(false); + const [detectError, setDetectError] = useState(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 /> +
+ +
+ {detectError ?
{detectError}
: null} + {formError ?
{formError}
: null}
@@ -421,6 +490,27 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C />
+
+ +
+ {detectError ?
{detectError}
: null} + {formError ?
{formError}
: null}
diff --git a/packages/dashboard/app/components/__tests__/CustomProviderForm.test.tsx b/packages/dashboard/app/components/__tests__/CustomProviderForm.test.tsx index 3a88e8c67..3a46e6f17 100644 --- a/packages/dashboard/app/components/__tests__/CustomProviderForm.test.tsx +++ b/packages/dashboard/app/components/__tests__/CustomProviderForm.test.tsx @@ -1,7 +1,8 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { CustomProviderForm } from "../CustomProviderForm"; +import * as api from "../../api"; describe("CustomProviderForm", () => { it("renders base fields", () => { @@ -58,3 +59,187 @@ describe("CustomProviderForm", () => { expect(screen.getByText("Request failed")).toBeInTheDocument(); }); }); + +describe("Detect Models", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("shows the Detect Models button for openai-completions API type", () => { + render( + + ); + expect(screen.getByRole("button", { name: /detect models/i })).toBeInTheDocument(); + }); + + it("shows the Detect Models button for openai-responses API type", () => { + render( + + ); + expect(screen.getByRole("button", { name: /detect models/i })).toBeInTheDocument(); + }); + + it("shows the Detect Models button for anthropic-messages API type", () => { + render( + + ); + expect(screen.getByRole("button", { name: /detect models/i })).toBeInTheDocument(); + }); + + it("shows the Detect Models button for google-generative-ai API type", () => { + render( + + ); + expect(screen.getByRole("button", { name: /detect models/i })).toBeInTheDocument(); + }); + + it("calls probeProviderModels and adds discovered models", async () => { + const mockProbe = vi.spyOn(api, "probeProviderModels").mockResolvedValue({ + models: [ + { id: "gpt-4o", name: "GPT 4o", reasoning: false }, + { id: "gpt-4", name: "GPT 4", reasoning: false }, + ], + count: 2, + }); + + render( + + ); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /detect models/i })); + + expect(mockProbe).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://api.example.com/v1", + apiKey: "sk-test", + apiType: "openai-compatible", + }) + ); + + // Models should be added to the list + expect(screen.getByDisplayValue("gpt-4o")).toBeInTheDocument(); + expect(screen.getByDisplayValue("gpt-4")).toBeInTheDocument(); + }); + + it("deduplicates models when detecting", async () => { + const mockProbe = vi.spyOn(api, "probeProviderModels").mockResolvedValue({ + models: [ + { id: "gpt-4o", name: "GPT 4o", reasoning: false }, + { id: "gpt-4", name: "GPT 4", reasoning: false }, + ], + count: 2, + }); + + render( + + ); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /detect models/i })); + + // gpt-4o should appear only once (existing + deduplicated) + const gpt4oInputs = screen.queryAllByDisplayValue("gpt-4o"); + expect(gpt4oInputs).toHaveLength(1); + // gpt-4 should be added + expect(screen.getByDisplayValue("gpt-4")).toBeInTheDocument(); + }); + + it("shows error when detection fails", async () => { + const mockProbe = vi.spyOn(api, "probeProviderModels").mockRejectedValue( + new Error("Provider returned 401 Unauthorized") + ); + + render( + + ); + + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: /detect models/i })); + + expect(screen.getByText("Provider returned 401 Unauthorized")).toBeInTheDocument(); + }); + + it("disables button when baseUrl is empty", () => { + render( + + ); + const detectBtn = screen.getByRole("button", { name: /detect models/i }); + expect(detectBtn).toBeDisabled(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx b/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx index 47d5d9d0a..81e3ab99d 100644 --- a/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx +++ b/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx @@ -20,6 +20,7 @@ vi.mock("lucide-react", () => ({ Loader2: ({ className }: { className?: string }) => , Pencil: () => , Plus: () => , + Search: () => , Trash2: () => , })); diff --git a/packages/dashboard/src/routes/__tests__/custom-providers.test.ts b/packages/dashboard/src/routes/__tests__/custom-providers.test.ts index 211bfdb74..036c675a4 100644 --- a/packages/dashboard/src/routes/__tests__/custom-providers.test.ts +++ b/packages/dashboard/src/routes/__tests__/custom-providers.test.ts @@ -352,3 +352,281 @@ describe("custom providers API routes", () => { expect(String(res.body.error)).toContain("not found"); }); }); + +describe("POST /api/custom-providers/probe-models", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("returns OpenAI-compatible models", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { id: "gpt-4o", object: "model", owned_by: "system" }, + { id: "gpt-4", object: "model", owned_by: "system" }, + ], + }), + }); + + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + baseUrl: "https://api.openai.com/v1", + apiType: "openai-compatible", + apiKey: "sk-test", + }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + count: 2, + models: [ + { id: "gpt-4o", name: "gpt-4o", reasoning: false }, + { id: "gpt-4", name: "gpt-4", reasoning: false }, + ], + }); + }); + + it("returns Anthropic-compatible models", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { id: "claude-sonnet-4-20250514", object: "model", display_name: "Claude Sonnet 4" }, + { id: "claude-haiku-4-5-20251001", object: "model", display_name: "Claude Haiku 4.5" }, + { id: "claude-opus-4-20250514", object: "model", display_name: "Claude Opus 4" }, + ], + }), + }); + + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + baseUrl: "https://api.anthropic.com", + apiType: "anthropic-compatible", + apiKey: "sk-ant-test", + }); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(3); + expect(res.body.models[0]).toEqual({ + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + reasoning: true, // sonnet detected as reasoning + }); + expect(res.body.models[2]).toEqual({ + id: "claude-opus-4-20250514", + name: "Claude Opus 4", + reasoning: true, // opus detected as reasoning + }); + }); + + it("returns Google Generative AI models", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [ + { + name: "models/gemini-2.0-flash", + baseModelId: "gemini-2.0-flash", + displayName: "Gemini 2.0 Flash", + inputTokenLimit: 1048576, + outputTokenLimit: 8192, + supportedGenerationMethods: ["generateContent"], + }, + { + name: "models/text-embedding-004", + baseModelId: "text-embedding-004", + displayName: "Text Embedding", + supportedGenerationMethods: ["embedContent"], + }, + ], + }), + }); + + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + baseUrl: "https://generativelanguage.googleapis.com", + apiType: "google-generative-ai", + apiKey: "AIza-test", + }); + + expect(res.status).toBe(200); + // Embedding model should be filtered out + expect(res.body.count).toBe(1); + expect(res.body.models[0]).toEqual({ + id: "gemini-2.0-flash", + name: "Gemini 2.0 Flash", + reasoning: false, + contextWindow: 1048576, + maxTokens: 8192, + }); + }); + + it("excludes embedding models from OpenAI-compatible response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { id: "gpt-4o", object: "model", modalities: { input: ["text"], output: ["text"] } }, + { id: "text-embedding-3", object: "model", modalities: { input: ["text"], output: ["embedding"] } }, + { id: "whisper-large", object: "model", modalities: { input: ["audio"], output: ["text"] } }, + ], + }), + }); + + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + baseUrl: "https://api.example.com/v1", + apiType: "openai-compatible", + }); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); // embedding + audio-input both excluded + expect(res.body.models[0].id).toBe("gpt-4o"); + }); + + it("excludes models without text input from OpenAI-compatible response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { id: "gpt-4o", object: "model", modalities: { input: ["text", "image"], output: ["text"] } }, + { id: "scribe-v2", object: "model", modalities: { input: ["audio"], output: ["text"] } }, + { id: "eleven-v3", object: "model", modalities: { input: ["text"], output: ["audio"] } }, + ], + }), + }); + + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + baseUrl: "https://api.example.com/v1", + apiType: "openai-compatible", + }); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); // only gpt-4o has text input + text output + expect(res.body.models[0].id).toBe("gpt-4o"); + }); + + it("rejects invalid apiType for probe", async () => { + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + baseUrl: "https://api.example.com", + apiType: "invalid", + }); + + expect(res.status).toBe(400); + }); + + it("detects reasoning models from ID", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: [ + { id: "o1-preview", object: "model" }, + { id: "o3-mini", object: "model" }, + { id: "gpt-4o", object: "model" }, + ], + }), + }); + + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + baseUrl: "https://api.openai.com/v1", + apiType: "openai-compatible", + }); + + expect(res.body.models[0].reasoning).toBe(true); // o1-preview + expect(res.body.models[1].reasoning).toBe(true); // o3-mini + expect(res.body.models[2].reasoning).toBe(false); // gpt-4o + }); + + it("returns 400 for missing baseUrl", async () => { + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + apiType: "openai-compatible", + }); + + expect(res.status).toBe(400); + }); + + it("returns 400 for invalid URL", async () => { + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + baseUrl: "not-a-url", + apiType: "openai-compatible", + }); + + expect(res.status).toBe(400); + }); + + it("returns error when provider returns non-200", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "Invalid API key", + }); + + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + baseUrl: "https://api.openai.com/v1", + apiType: "openai-compatible", + apiKey: "sk-invalid", + }); + + expect(res.status).toBe(401); + }); + + it("handles { models: [...] } response format", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + models: [ + { id: "llama-3.1-8b", name: "Llama 3.1 8B" }, + ], + }), + }); + + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + baseUrl: "https://api.example.com", + apiType: "openai-compatible", + }); + + expect(res.status).toBe(200); + expect(res.body.models[0]).toEqual({ + id: "llama-3.1-8b", + name: "Llama 3.1 8B", + reasoning: false, + }); + }); + + it("truncates large model lists to 100", async () => { + const manyModels = Array.from({ length: 150 }, (_, i) => ({ + id: `model-${i}`, + object: "model", + })); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ data: manyModels }), + }); + + const app = setupApp(createCustomProviderStore().store); + const res = await doRequest(app, "POST", "/api/custom-providers/probe-models", { + baseUrl: "https://api.example.com", + apiType: "openai-compatible", + }); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(100); + expect(res.body.models.length).toBe(100); + }); +}); diff --git a/packages/dashboard/src/routes/register-custom-provider-routes.ts b/packages/dashboard/src/routes/register-custom-provider-routes.ts index fd07b0eb7..44932689d 100644 --- a/packages/dashboard/src/routes/register-custom-provider-routes.ts +++ b/packages/dashboard/src/routes/register-custom-provider-routes.ts @@ -103,6 +103,199 @@ function parseCreateBody(body: unknown): Omit { return provider; } +interface ProbeModelResult { + id: string; + name: string; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; +} + +const MAX_PROBE_MODELS = 100; + +type ProbeApiType = "openai-compatible" | "anthropic-compatible" | "google-generative-ai"; + +/** + * Check if a model should be excluded (embedding / reranking / audio-only / no-text-input models). + */ +function isNonChatModel(m: Record): boolean { + // OpenAI-compatible modalities: { input: ["text"], output: ["embedding"] } + const modalities = m.modalities as Record | undefined; + if (modalities) { + // Exclude models that don't accept text input (e.g. audio-only, image-only) + if (Array.isArray(modalities.input)) { + const inputs = modalities.input.map((i: unknown) => String(i).toLowerCase()); + if (!inputs.includes("text")) { + return true; + } + } + + if (Array.isArray(modalities.output)) { + const outputs = modalities.output.map((o: unknown) => String(o).toLowerCase()); + if (outputs.includes("embedding") || outputs.includes("scores")) { + return true; + } + // Exclude models that don't produce text output (e.g. audio-only) + if (!outputs.includes("text")) { + return true; + } + } + } + + // Google supportedGenerationMethods: no generateContent = not a chat model + const methods = m.supportedGenerationMethods as unknown[] | undefined; + if (Array.isArray(methods) && methods.length > 0 && !methods.includes("generateContent")) { + return true; + } + + // Heuristic: model ID contains embedding / rerank + const id = String(m.id ?? m.name ?? "").toLowerCase(); + if (id.includes("embedding") || id.includes("embed-") || id.includes("-embed-") || id.includes("rerank")) { + return true; + } + + return false; +} + +/** + * Probe a custom provider's /models endpoint to discover available models. + * Supports OpenAI-compatible, Anthropic-compatible, and Google Generative AI providers. + */ +async function probeProviderModels( + baseUrl: string, + apiKey: string | undefined, + apiType: ProbeApiType, +): Promise { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw badRequest("baseUrl must be a valid URL"); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw badRequest("baseUrl must use http or https"); + } + + let modelsUrl: string; + const headers: Record = { + "Content-Type": "application/json", + "User-Agent": "Fusion/1.0", + }; + + if (apiType === "openai-compatible") { + // OpenAI-compatible: /v1/models relative to baseUrl + const pathname = url.pathname.replace(/\/+$/, ""); + const modelsPath = pathname ? pathname + "/models" : "/models"; + modelsUrl = new URL(modelsPath, url.origin).toString(); + if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + } else if (apiType === "anthropic-compatible") { + // Anthropic: GET /v1/models with x-api-key header + const pathname = url.pathname.replace(/\/+$/, ""); + const modelsPath = pathname ? pathname + "/models" : "/v1/models"; + modelsUrl = new URL(modelsPath, url.origin).toString(); + if (apiKey) headers["x-api-key"] = apiKey; + headers["anthropic-version"] = "2023-06-01"; + } else { + // Google Generative AI: GET /v1beta/models?key=API_KEY + const pathname = url.pathname.replace(/\/+$/, ""); + const modelsPath = pathname ? pathname + "/models" : "/v1beta/models"; + modelsUrl = new URL(modelsPath, url.origin).toString(); + if (apiKey) { + // Append API key as query parameter (Google convention) + const separator = modelsUrl.includes("?") ? "&" : "?"; + modelsUrl = `${modelsUrl}${separator}key=${encodeURIComponent(apiKey)}`; + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + + try { + const response = await fetch(modelsUrl, { + method: "GET", + headers, + signal: controller.signal, + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + const message = errorBody.slice(0, 200); + throw new ApiError( + response.status, + `Provider returned ${response.status} ${response.statusText}${message ? `: ${message}` : ""}`, + ); + } + + const data = await response.json(); + const rawModels = data?.data ?? data?.models ?? []; + + if (!Array.isArray(rawModels) || rawModels.length === 0) { + throw new ApiError(404, "No models found in provider response"); + } + + // Filter out embedding/reranking/audio-only models and truncate + const chatModels = rawModels.filter((m: Record) => !isNonChatModel(m)); + const trimmed = chatModels.length > MAX_PROBE_MODELS ? chatModels.slice(0, MAX_PROBE_MODELS) : chatModels; + + return trimmed.map((m: Record) => { + // Extract ID based on provider format + let id: string; + let name: string; + let contextWindow: number | undefined; + let maxTokens: number | undefined; + let reasoning: boolean; + + if (apiType === "google-generative-ai") { + // Google: name = "models/gemini-2.0-flash", baseModelId = "gemini-2.0-flash" + id = String(m.baseModelId ?? m.name ?? ""); + // Strip "models/" prefix if present + if (id.startsWith("models/")) id = id.slice(7); + name = String(m.displayName ?? id); + contextWindow = typeof m.inputTokenLimit === "number" && m.inputTokenLimit > 0 + ? m.inputTokenLimit + : undefined; + maxTokens = typeof m.outputTokenLimit === "number" && m.outputTokenLimit > 0 + ? m.outputTokenLimit + : undefined; + reasoning = Boolean(m.thinking); + } else if (apiType === "anthropic-compatible") { + // Anthropic: id = "claude-sonnet-4-20250514", display_name = "Claude Sonnet 4" + id = String(m.id ?? ""); + name = String(m.display_name ?? id); + // Anthropic doesn't return context/max_tokens in the models list + reasoning = Boolean( + id.toLowerCase().includes("opus") || + id.toLowerCase().includes("sonnet"), + ); + } else { + // OpenAI-compatible + id = String(m.id ?? ""); + name = String(m.name ?? m.display_name ?? id); + reasoning = Boolean( + m.reasoning || + (Array.isArray(m.capabilities) && m.capabilities.includes("reasoning")) || + id.toLowerCase().includes("reason") || + id.toLowerCase().includes("o1") || + id.toLowerCase().includes("o3"), + ); + // Extract context window and max tokens from limit object + const limit = m.limit as Record | undefined; + contextWindow = typeof limit?.context === "number" && limit.context > 0 + ? limit.context + : undefined; + maxTokens = typeof limit?.output === "number" && limit.output > 0 + ? limit.output + : undefined; + } + + return { id, name, reasoning, contextWindow, maxTokens }; + }).filter((m) => m.id.length > 0); + } finally { + clearTimeout(timeoutId); + } +} + function parseUpdateBody(body: unknown): Partial> { if (!body || typeof body !== "object") { throw badRequest("request body must be an object"); @@ -245,4 +438,39 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => { rethrowAsApiError(err); } }); + + // NOTE: probe-models must be registered AFTER the :id param routes + // so Express does not match "probe-models" as an :id value. + router.post("/custom-providers/probe-models", async (req, res) => { + try { + const body = req.body as Record; + + const baseUrl = assertBaseUrl(body.baseUrl); + const apiKey = + typeof body.apiKey === "string" && body.apiKey.trim().length > 0 + ? body.apiKey.trim() + : undefined; + + // Probe endpoint accepts all three API types + const rawApiType = body.apiType as string | undefined; + if ( + rawApiType !== "openai-compatible" && + rawApiType !== "anthropic-compatible" && + rawApiType !== "google-generative-ai" + ) { + throw badRequest( + "apiType must be 'openai-compatible', 'anthropic-compatible', or 'google-generative-ai'", + ); + } + const apiType = rawApiType as ProbeApiType; + + const models = await probeProviderModels(baseUrl, apiKey, apiType); + res.json({ models, count: models.length }); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); }; From 55c35cf6115c5715fae437eea90bafecd2fbc69c Mon Sep 17 00:00:00 2001 From: Islam Nofl Date: Thu, 14 May 2026 16:56:11 +0300 Subject: [PATCH 2/4] fix: Google provider save after model detection, SSRF protection, PR review fixes - Add google-generative-ai to CustomProvider.apiType union type - Update assertApiType to accept google-generative-ai in create/update - Fix createCustomProvider mapping in legacy.ts for Google type - Fix fetchCustomProviders mapping for Google type - Add google-generative-ai to CustomProvidersSection API_TYPES - Add SSRF protection to probeProviderModels (block private/loopback) - Add body validation to probe-models route handler - Update stale JSDoc in legacy.ts --- .changeset/fix-google-provider-create.md | 5 ++ packages/core/src/types.ts | 2 +- packages/dashboard/app/api/legacy.ts | 13 +++-- .../app/components/CustomProvidersSection.tsx | 2 +- .../routes/__tests__/custom-providers.test.ts | 2 +- .../routes/register-custom-provider-routes.ts | 50 +++++++++++++++++-- 6 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-google-provider-create.md diff --git a/.changeset/fix-google-provider-create.md b/.changeset/fix-google-provider-create.md new file mode 100644 index 000000000..bf066ca7a --- /dev/null +++ b/.changeset/fix-google-provider-create.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Fix Google Generative AI custom provider not saving after model detection. The probe endpoint accepted `google-generative-ai` but create/update routes rejected it. Also adds SSRF protection, body validation, and fixes stale type mappings. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8bebcfb67..754065a6c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -311,7 +311,7 @@ export interface NotificationProviderConfig { export interface CustomProvider { id: string; name: string; - apiType: "openai-compatible" | "anthropic-compatible"; + apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai"; baseUrl: string; apiKey?: string; models?: { id: string; name: string }[]; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index e9ecaf7a6..e0ed511dc 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -1770,7 +1770,7 @@ export function setLlamaCppEnabled( export interface CustomProvider { id: string; name: string; - apiType: "openai-compatible" | "anthropic-compatible"; + apiType: "openai-compatible" | "anthropic-compatible" | "google-generative-ai"; baseUrl: string; apiKey?: string; models?: { id: string; name: string }[]; @@ -1782,7 +1782,9 @@ export async function fetchCustomProviders(): Promise ({ id: model.id, name: model.name })), } satisfies CustomProviderConfig)); @@ -1855,7 +1857,9 @@ export interface CustomProviderConfig { } export function createCustomProvider(config: CustomProviderConfig): Promise { - const apiType = config.api === "anthropic-messages" ? "anthropic-compatible" : "openai-compatible"; + const apiType = config.api === "anthropic-messages" ? "anthropic-compatible" + : config.api === "google-generative-ai" ? "google-generative-ai" + : "openai-compatible"; return addCustomProvider({ name: config.name?.trim() || config.id, apiType, @@ -1870,8 +1874,7 @@ export function createCustomProvider(config: CustomProviderConfig): Promise { }); expect(res.status).toBe(400); - expect(String(res.body.error)).toContain("apiType must be either"); + expect(String(res.body.error)).toContain("apiType must be"); }); it("POST /api/custom-providers rejects invalid baseUrl format", async () => { diff --git a/packages/dashboard/src/routes/register-custom-provider-routes.ts b/packages/dashboard/src/routes/register-custom-provider-routes.ts index 44932689d..20dd1af3a 100644 --- a/packages/dashboard/src/routes/register-custom-provider-routes.ts +++ b/packages/dashboard/src/routes/register-custom-provider-routes.ts @@ -1,4 +1,6 @@ import crypto from "node:crypto"; +import dns from "node:dns/promises"; +import net from "node:net"; import type { CustomProvider } from "@fusion/core"; import { ApiError, badRequest, notFound } from "../api-error.js"; import type { ApiRouteRegistrar } from "./types.js"; @@ -29,8 +31,8 @@ function assertNonEmptyString(value: unknown, fieldName: string): string { } function assertApiType(value: unknown): CustomProvider["apiType"] { - if (value !== "openai-compatible" && value !== "anthropic-compatible") { - throw badRequest("apiType must be either 'openai-compatible' or 'anthropic-compatible'"); + if (value !== "openai-compatible" && value !== "anthropic-compatible" && value !== "google-generative-ai") { + throw badRequest("apiType must be 'openai-compatible', 'anthropic-compatible', or 'google-generative-ai'"); } return value; } @@ -176,6 +178,42 @@ async function probeProviderModels( if (url.protocol !== "http:" && url.protocol !== "https:") { throw badRequest("baseUrl must use http or https"); } + // SSRF protection: reject private/loopback/link-local hosts + const hostname = url.hostname.toLowerCase(); + if ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "::1" || + hostname === "[::1]" || + hostname.endsWith(".local") || + hostname.endsWith(".internal") + ) { + throw badRequest("baseUrl must not be a loopback or private address"); + } + // Resolve hostname to IP and check against private ranges. + // If resolution fails, let the fetch attempt proceed naturally. + try { + const resolved = await dns.lookup(hostname, { all: true }); + const addresses = resolved.map((a) => a.address); + for (const addr of addresses) { + if (net.isIP(addr) === 0) continue; + const parts = addr.split(".").map(Number); + if (parts.length === 4 && !Number.isNaN(parts[0])) { + // 127.0.0.0/8 + if (parts[0] === 127) throw badRequest("baseUrl must not be a loopback or private address"); + // 10.0.0.0/8 + if (parts[0] === 10) throw badRequest("baseUrl must not be a loopback or private address"); + // 172.16.0.0/12 + if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) throw badRequest("baseUrl must not be a loopback or private address"); + // 192.168.0.0/16 + if (parts[0] === 192 && parts[1] === 168) throw badRequest("baseUrl must not be a loopback or private address"); + // 169.254.0.0/16 (link-local, includes cloud metadata) + if (parts[0] === 169 && parts[1] === 254) throw badRequest("baseUrl must not be a loopback or private address"); + } + } + } catch { + // DNS resolution failed — proceed without SSRF check; the fetch will fail naturally + } let modelsUrl: string; const headers: Record = { @@ -439,10 +477,13 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => { } }); - // NOTE: probe-models must be registered AFTER the :id param routes - // so Express does not match "probe-models" as an :id value. + // NOTE: probe-models must be registered AFTER the :id param routes + // so Express does not match "probe-models" as an :id value. router.post("/custom-providers/probe-models", async (req, res) => { try { + if (!req.body || typeof req.body !== "object") { + throw badRequest("request body must be an object"); + } const body = req.body as Record; const baseUrl = assertBaseUrl(body.baseUrl); @@ -451,7 +492,6 @@ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => { ? body.apiKey.trim() : undefined; - // Probe endpoint accepts all three API types const rawApiType = body.apiType as string | undefined; if ( rawApiType !== "openai-compatible" && From e9b8111d93ba2ce260e45db22c11ec78cdc5b160 Mon Sep 17 00:00:00 2001 From: Islam Nofl Date: Thu, 14 May 2026 17:32:22 +0300 Subject: [PATCH 3/4] fix: re-throw ApiError in SSRF catch block so private-IP guards actually work The bare catch {} swallowed the ApiError thrown by the private-IP checks, defeating the entire DNS-based SSRF protection. Now catches and re-throws ApiError so security rejections propagate correctly; only DNS-lookup failures fall through. --- .../dashboard/src/routes/register-custom-provider-routes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/dashboard/src/routes/register-custom-provider-routes.ts b/packages/dashboard/src/routes/register-custom-provider-routes.ts index 20dd1af3a..9767e809f 100644 --- a/packages/dashboard/src/routes/register-custom-provider-routes.ts +++ b/packages/dashboard/src/routes/register-custom-provider-routes.ts @@ -211,7 +211,8 @@ async function probeProviderModels( if (parts[0] === 169 && parts[1] === 254) throw badRequest("baseUrl must not be a loopback or private address"); } } - } catch { + } catch (err) { + if (err instanceof ApiError) throw err; // DNS resolution failed — proceed without SSRF check; the fetch will fail naturally } From 96311245f7f09686f0adfe74f672d76586896f31 Mon Sep 17 00:00:00 2001 From: Islam Nofl Date: Fri, 15 May 2026 07:12:48 +0300 Subject: [PATCH 4/4] fix: resolve PR check failures - SSRF: Add IPv6 private range checks (loopback, ULA, link-local, IPv4-mapped) after DNS resolution, closing the IPv6 bypass - Deduplication: Add Set-based dedup in CustomProvidersSection handleDetectModels to prevent duplicate model accumulation - Bump type: change changeset from patch to minor for new feature - Reasoning: Narrow Anthropic sonnet reasoning detection to only flag models containing both 'sonnet' and 'think' - Docs: Add JSDoc to all undocumented functions in route file, raising docstring coverage above the 80% threshold - Test: Update sonnet reasoning expectation (standard sonnet is not a thinking model) --- .changeset/fix-google-provider-create.md | 2 +- .../app/components/CustomProvidersSection.tsx | 13 ++-- .../routes/__tests__/custom-providers.test.ts | 2 +- .../routes/register-custom-provider-routes.ts | 61 ++++++++++++++++++- 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/.changeset/fix-google-provider-create.md b/.changeset/fix-google-provider-create.md index bf066ca7a..8f6965610 100644 --- a/.changeset/fix-google-provider-create.md +++ b/.changeset/fix-google-provider-create.md @@ -1,5 +1,5 @@ --- -"@runfusion/fusion": patch +"@runfusion/fusion": minor --- Fix Google Generative AI custom provider not saving after model detection. The probe endpoint accepted `google-generative-ai` but create/update routes rejected it. Also adds SSRF protection, body validation, and fixes stale type mappings. diff --git a/packages/dashboard/app/components/CustomProvidersSection.tsx b/packages/dashboard/app/components/CustomProvidersSection.tsx index 1fa49655f..c349ac85b 100644 --- a/packages/dashboard/app/components/CustomProvidersSection.tsx +++ b/packages/dashboard/app/components/CustomProvidersSection.tsx @@ -193,13 +193,16 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C }); if (result.models.length > 0) { - const discoveredIds = result.models.map((m) => m.id).join(", "); setModels((prev) => { + const existingIds = new Set( + prev.split(",").map((s) => s.trim()).filter(Boolean), + ); + const newIds = result.models + .map((m) => m.id.trim()) + .filter((id) => !existingIds.has(id)); + if (newIds.length === 0) return prev; const existing = prev.trim(); - if (existing) { - return discoveredIds + ", " + existing; - } - return discoveredIds; + return newIds.join(", ") + (existing ? ", " + existing : ""); }); } else { setDetectError("No models found. The provider may require an API key."); diff --git a/packages/dashboard/src/routes/__tests__/custom-providers.test.ts b/packages/dashboard/src/routes/__tests__/custom-providers.test.ts index ca2669992..420c8047f 100644 --- a/packages/dashboard/src/routes/__tests__/custom-providers.test.ts +++ b/packages/dashboard/src/routes/__tests__/custom-providers.test.ts @@ -418,7 +418,7 @@ describe("POST /api/custom-providers/probe-models", () => { expect(res.body.models[0]).toEqual({ id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", - reasoning: true, // sonnet detected as reasoning + reasoning: false, // standard sonnet without thinking capability }); expect(res.body.models[2]).toEqual({ id: "claude-opus-4-20250514", diff --git a/packages/dashboard/src/routes/register-custom-provider-routes.ts b/packages/dashboard/src/routes/register-custom-provider-routes.ts index 9767e809f..a9b1aea48 100644 --- a/packages/dashboard/src/routes/register-custom-provider-routes.ts +++ b/packages/dashboard/src/routes/register-custom-provider-routes.ts @@ -5,6 +5,9 @@ import type { CustomProvider } from "@fusion/core"; import { ApiError, badRequest, notFound } from "../api-error.js"; import type { ApiRouteRegistrar } from "./types.js"; +/** + * Masks an API key for safe display, showing only the first 3 and last 4 characters. + */ function maskApiKey(key: string): string { if (key.length <= 8) { return "••••••••"; @@ -12,6 +15,9 @@ function maskApiKey(key: string): string { return key.slice(0, 3) + "•••••" + key.slice(-4); } +/** + * Removes the raw API key from a provider object, replacing it with a masked version. + */ function sanitizeProvider(provider: CustomProvider): CustomProvider { if (!provider.apiKey) { return provider; @@ -23,6 +29,10 @@ function sanitizeProvider(provider: CustomProvider): CustomProvider { }; } +/** + * Asserts that a value is a non-empty string and returns the trimmed value. + * @throws {ApiError} with status 400 if the value is not a non-empty string. + */ function assertNonEmptyString(value: unknown, fieldName: string): string { if (typeof value !== "string" || value.trim().length === 0) { throw badRequest(`${fieldName} is required and must be a non-empty string`); @@ -30,6 +40,10 @@ function assertNonEmptyString(value: unknown, fieldName: string): string { return value.trim(); } +/** + * Asserts that a value is a valid custom provider API type. + * @throws {ApiError} with status 400 if the type is not recognized. + */ function assertApiType(value: unknown): CustomProvider["apiType"] { if (value !== "openai-compatible" && value !== "anthropic-compatible" && value !== "google-generative-ai") { throw badRequest("apiType must be 'openai-compatible', 'anthropic-compatible', or 'google-generative-ai'"); @@ -37,6 +51,10 @@ function assertApiType(value: unknown): CustomProvider["apiType"] { return value; } +/** + * Asserts that a value is a valid HTTP/HTTPS URL suitable for use as a base URL. + * @throws {ApiError} with status 400 if the URL is invalid or uses an unsupported protocol. + */ function assertBaseUrl(value: unknown): string { const baseUrl = assertNonEmptyString(value, "baseUrl"); @@ -54,6 +72,11 @@ function assertBaseUrl(value: unknown): string { return baseUrl; } +/** + * Validates and normalizes a models array from a request body. + * Returns undefined if models is omitted, or an array of { id, name } objects. + * @throws {ApiError} with status 400 if the structure is invalid. + */ function validateModels(value: unknown): Array<{ id: string; name: string }> | undefined { if (value === undefined) { return undefined; @@ -76,6 +99,11 @@ function validateModels(value: unknown): Array<{ id: string; name: string }> | u }); } +/** + * Parses and validates the body of a create-custom-provider request. + * Returns all required and optional fields except the auto-generated id. + * @throws {ApiError} with status 400 if required fields are missing or invalid. + */ function parseCreateBody(body: unknown): Omit { if (!body || typeof body !== "object") { throw badRequest("request body must be an object"); @@ -209,6 +237,27 @@ async function probeProviderModels( if (parts[0] === 192 && parts[1] === 168) throw badRequest("baseUrl must not be a loopback or private address"); // 169.254.0.0/16 (link-local, includes cloud metadata) if (parts[0] === 169 && parts[1] === 254) throw badRequest("baseUrl must not be a loopback or private address"); + } else if (net.isIPv6(addr)) { + const lower = addr.toLowerCase(); + // ::1 — IPv6 loopback + if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") throw badRequest("baseUrl must not be a loopback or private address"); + // fc00::/7 — Unique Local Addresses (private, RFC 4193) + if (lower.startsWith("fc") || lower.startsWith("fd")) throw badRequest("baseUrl must not be a loopback or private address"); + // fe80::/10 — link-local addresses + if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) throw badRequest("baseUrl must not be a loopback or private address"); + // ::ffff:0:0/96 — IPv4-mapped IPv6 — extract embedded IPv4 and re-check + const ipv4Mapped = lower.match(/::ffff:(\d+\.\d+\.\d+\.\d+)/); + if (ipv4Mapped) { + const v4Parts = ipv4Mapped[1].split(".").map(Number); + if (v4Parts.length === 4) { + if (v4Parts[0] === 127 || v4Parts[0] === 10 || + (v4Parts[0] === 172 && v4Parts[1] >= 16 && v4Parts[1] <= 31) || + (v4Parts[0] === 192 && v4Parts[1] === 168) || + (v4Parts[0] === 169 && v4Parts[1] === 254)) { + throw badRequest("baseUrl must not be a loopback or private address"); + } + } + } } } } catch (err) { @@ -305,7 +354,7 @@ async function probeProviderModels( // Anthropic doesn't return context/max_tokens in the models list reasoning = Boolean( id.toLowerCase().includes("opus") || - id.toLowerCase().includes("sonnet"), + (id.toLowerCase().includes("sonnet") && id.toLowerCase().includes("think")), ); } else { // OpenAI-compatible @@ -335,6 +384,11 @@ async function probeProviderModels( } } +/** + * Parses and validates the body of an update-custom-provider request. + * Returns an object with only the fields that were provided for partial updates. + * @throws {ApiError} with status 400 if provided fields are invalid. + */ function parseUpdateBody(body: unknown): Partial> { if (!body || typeof body !== "object") { throw badRequest("request body must be an object"); @@ -365,6 +419,11 @@ function parseUpdateBody(body: unknown): Partial> { return updates; } +/** + * Registers custom provider CRUD routes and the probe-models endpoint. + * Routes are ordered so that static paths (probe-models) are registered after + * parameterized paths (:id) to avoid Express route conflicts. + */ export const registerCustomProviderRoutes: ApiRouteRegistrar = (ctx) => { const { router, store, rethrowAsApiError } = ctx;