diff --git a/alembic/versions/d5e1a2c3f4b6_add_custom_provider_type.py b/alembic/versions/d5e1a2c3f4b6_add_custom_provider_type.py
new file mode 100644
index 0000000000..df3f65f729
--- /dev/null
+++ b/alembic/versions/d5e1a2c3f4b6_add_custom_provider_type.py
@@ -0,0 +1,44 @@
+"""add custom provider type
+
+Adds an explicit ``type`` discriminator to ``agent_custom_provider`` so
+discovery and validation dispatch on a stored value instead of inferring from
+display name or base URL. The column is a plain string (mirroring
+``agent_catalog.model_provider``), not a DB enum, so new types can ship
+without an enum migration.
+
+Additive and backfilled by the server default: existing providers read back as
+``generic_openai_compatible``, which preserves today's ``GET {base_url}/models``
+discovery behavior.
+
+Revision ID: d5e1a2c3f4b6
+Revises: c6a8d4f3b2e1
+Create Date: 2026-07-22 00:00:00.000000
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "d5e1a2c3f4b6"
+down_revision: str | None = "c6a8d4f3b2e1"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "agent_custom_provider",
+ sa.Column(
+ "type",
+ sa.String(length=120),
+ nullable=False,
+ server_default="generic_openai_compatible",
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("agent_custom_provider", "type")
diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts
index ea4264be39..14c9d1a33d 100644
--- a/frontend/src/client/schemas.gen.ts
+++ b/frontend/src/client/schemas.gen.ts
@@ -1480,6 +1480,10 @@ export const $AgentCustomProviderCreate = {
],
title: "Base Url",
},
+ type: {
+ $ref: "#/components/schemas/CustomProviderType",
+ default: "generic_openai_compatible",
+ },
passthrough: {
type: "boolean",
title: "Passthrough",
@@ -1583,6 +1587,9 @@ export const $AgentCustomProviderRead = {
],
title: "Base Url",
},
+ type: {
+ $ref: "#/components/schemas/CustomProviderType",
+ },
passthrough: {
type: "boolean",
title: "Passthrough",
@@ -1617,6 +1624,7 @@ export const $AgentCustomProviderRead = {
"organization_id",
"display_name",
"base_url",
+ "type",
"passthrough",
"api_key_header",
"last_refreshed_at",
@@ -1651,6 +1659,16 @@ export const $AgentCustomProviderUpdate = {
],
title: "Base Url",
},
+ type: {
+ anyOf: [
+ {
+ $ref: "#/components/schemas/CustomProviderType",
+ },
+ {
+ type: "null",
+ },
+ ],
+ },
passthrough: {
anyOf: [
{
@@ -11509,6 +11527,13 @@ export const $CustomOAuthProviderCreate = {
description: "Request payload for creating a custom OAuth provider.",
} as const
+export const $CustomProviderType = {
+ type: "string",
+ enum: ["generic_openai_compatible", "litellm", "ollama"],
+ title: "CustomProviderType",
+ description: "Explicit provider type driving discovery and validation.",
+} as const
+
export const $DSLConfig_Input = {
properties: {
scheduler: {
diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts
index 48044ae161..3bc437ba85 100644
--- a/frontend/src/client/types.gen.ts
+++ b/frontend/src/client/types.gen.ts
@@ -409,6 +409,7 @@ export type AgentChannelTokenUpdate = {
export type AgentCustomProviderCreate = {
display_name: string
base_url?: string | null
+ type?: CustomProviderType
passthrough?: boolean
api_key_header?: string | null
api_key?: string | null
@@ -433,6 +434,7 @@ export type AgentCustomProviderRead = {
organization_id: string
display_name: string
base_url: string | null
+ type: CustomProviderType
passthrough: boolean
api_key_header: string | null
last_refreshed_at: string | null
@@ -444,6 +446,7 @@ export type AgentCustomProviderRead = {
export type AgentCustomProviderUpdate = {
display_name?: string | null
base_url?: string | null
+ type?: CustomProviderType | null
passthrough?: boolean | null
api_key_header?: string | null
api_key?: string | null
@@ -3330,6 +3333,14 @@ export type CustomOAuthProviderCreate = {
client_secret?: string | null
}
+/**
+ * Explicit provider type driving discovery and validation.
+ */
+export type CustomProviderType =
+ | "generic_openai_compatible"
+ | "litellm"
+ | "ollama"
+
/**
* This is the runtime configuration for the workflow.
*
diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx
index 1e91abae63..1ab302c426 100644
--- a/frontend/src/components/icons.tsx
+++ b/frontend/src/components/icons.tsx
@@ -822,6 +822,11 @@ export const providerIcons: Record<
),
+ litellm: ({ className, ...rest }) => (
+
+
+
+ ),
"manual-custom-source": ({ className, ...rest }) => (
@@ -1479,6 +1484,33 @@ export function VllmIcon({ className, ...rest }: IconProps) {
)
}
+export function LiteLLMIcon({ className, ...rest }: IconProps) {
+ // Official LiteLLM mark (selfh.st/icons, CC BY 4.0). Presented on a subtle
+ // tinted circle to match OllamaIcon; no dark background rect.
+ return (
+
+
+
+
+
+ )
+}
+
export function GoogleSheetsIcon({ className, ...rest }: IconProps) {
return (
void
+}) {
+ const queryClient = useQueryClient()
+ const form = useForm({
+ resolver: zodResolver(customProviderSchema),
+ mode: "onBlur",
+ defaultValues: getProviderDialogDefaults(provider),
+ })
+
+ const selectedType = form.watch("type")
+ const [advancedOpen, setAdvancedOpen] = useState(false)
+ const hasCustomHeadersError = !!form.formState.errors.customHeadersJson
+
+ useEffect(() => {
+ form.reset(getProviderDialogDefaults(provider))
+ setAdvancedOpen(false)
+ }, [form, provider, open])
+
+ useEffect(() => {
+ if (hasCustomHeadersError) {
+ setAdvancedOpen(true)
+ }
+ }, [hasCustomHeadersError])
+
+ const saveMutation = useMutation({
+ mutationFn: async (values: CustomProviderFormValues) =>
+ await updateCustomProvider({
+ providerId: provider.id,
+ requestBody: buildProviderUpdatePayload(values),
+ }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["organization", "agent-providers"],
+ })
+ onOpenChange(false)
+ toast({
+ title: "Custom source updated",
+ description: "Saved the custom source configuration.",
+ })
+ },
+ onError: (error: ApiError) => {
+ toast({
+ title: "Update failed",
+ description:
+ getApiErrorDetail(error) ?? "Unable to save the custom source.",
+ variant: "destructive",
+ })
+ },
+ })
+
+ const validateMutation = useMutation({
+ mutationFn: async (values: CustomProviderFormValues) =>
+ await validateCustomProviderConnection({
+ requestBody: buildProviderCreatePayload(values),
+ }),
+ onSuccess: (result) => {
+ toast({
+ title: result.valid ? "Connection looks good" : "Connection failed",
+ description: result.valid
+ ? "The provider responded successfully."
+ : "The provider did not respond successfully.",
+ variant: result.valid ? "default" : "destructive",
+ })
+ },
+ onError: (error: ApiError) => {
+ toast({
+ title: "Connection test failed",
+ description:
+ getApiErrorDetail(error) ?? "Unable to validate the custom source.",
+ variant: "destructive",
+ })
+ },
+ })
+
+ async function handleValidate() {
+ const valid = await form.trigger()
+ if (!valid) {
+ if (form.formState.errors.customHeadersJson) {
+ setAdvancedOpen(true)
+ }
+ return
+ }
+ await validateMutation.mutateAsync(form.getValues())
+ }
+
+ async function handleSubmit(values: CustomProviderFormValues) {
+ await saveMutation.mutateAsync(values)
+ }
+
+ return (
+
+
+
+ Edit custom source
+
+ Configure a user-defined LLM provider endpoint. Changing the type or
+ base URL re-runs discovery.
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/organization/custom-provider-fields.tsx b/frontend/src/components/organization/custom-provider-fields.tsx
new file mode 100644
index 0000000000..b390b972e4
--- /dev/null
+++ b/frontend/src/components/organization/custom-provider-fields.tsx
@@ -0,0 +1,279 @@
+import { ChevronDown } from "lucide-react"
+import type { UseFormReturn } from "react-hook-form"
+import type { CustomProviderType } from "@/client"
+import {
+ type CustomProviderFormValues,
+ typeSupportsCredentials,
+} from "@/components/organization/custom-provider-form"
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible"
+import {
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Switch } from "@/components/ui/switch"
+import { Textarea } from "@/components/ui/textarea"
+
+/**
+ * Base URL field with type-aware helper text (LiteLLM gets a proxy hint).
+ */
+export function BaseUrlField({
+ form,
+ type,
+}: {
+ form: UseFormReturn
+ type: CustomProviderType
+}) {
+ let placeholder = "https://gateway.example.com/v1"
+ if (type === "ollama") {
+ placeholder = "http://localhost:11434"
+ } else if (type === "litellm") {
+ placeholder = "http://localhost:4000"
+ }
+
+ return (
+ (
+
+ Base URL
+
+
+
+ {type === "litellm" ? (
+
+ The LiteLLM proxy base URL. Either the root or a{" "}
+
+ /v1
+ {" "}
+ suffix is accepted.
+
+ ) : null}
+ {type === "ollama" ? (
+
+ The Ollama server root. A trailing{" "}
+
+ /v1
+ {" "}
+ is optional and handled automatically; models are discovered from{" "}
+
+ /api/tags
+
+ .
+
+ ) : null}
+
+
+ )}
+ />
+ )
+}
+
+/**
+ * Credential fields (auth header + value). Hidden entirely for Ollama, which
+ * needs no API key.
+ */
+export function CredentialFields({
+ form,
+ type,
+ isEdit,
+}: {
+ form: UseFormReturn
+ type: CustomProviderType
+ isEdit: boolean
+}) {
+ if (!typeSupportsCredentials(type)) {
+ return null
+ }
+
+ return (
+
+ )
+}
+
+/**
+ * Surface the Advanced section is rendered on. The create wizard hides the
+ * passthrough control for litellm/ollama (silently created with the prefilled
+ * passthrough=true); the edit dialog always shows it.
+ */
+export type CustomProviderSurface = "wizard" | "edit"
+
+/**
+ * Whether the passthrough toggle is visible for this type on this surface.
+ *
+ * Hidden only in the create wizard for litellm/ollama, which are silently
+ * created with passthrough=true (the prefilled form value is still submitted
+ * verbatim). Visible everywhere else, including the edit dialog for all types.
+ */
+function isPassthroughVisible(
+ type: CustomProviderType,
+ surface: CustomProviderSurface
+): boolean {
+ if (surface === "wizard" && (type === "litellm" || type === "ollama")) {
+ return false
+ }
+ return true
+}
+
+/**
+ * Passthrough toggle shown inside the Advanced section. Rendered for every
+ * provider type; the wizard prefills a per-type default but the user is free
+ * to change it.
+ */
+function PassthroughField({
+ form,
+}: {
+ form: UseFormReturn
+}) {
+ return (
+ (
+
+
+ Passthrough mode
+
+ Recommended for bring-your-own gateways (LiteLLM, vLLM, etc.).
+ Skips Tracecat's transforms and forwards requests directly to
+ your endpoint.
+
+
+
+
+
+
+ )}
+ />
+ )
+}
+
+/**
+ * Additional static headers JSON field. Rendered inside the Advanced section.
+ * The API-key reference is dropped for Ollama, which has no API key field.
+ */
+function CustomHeadersField({
+ form,
+ type,
+}: {
+ form: UseFormReturn
+ type: CustomProviderType
+}) {
+ const nonAuthClause =
+ type === "ollama"
+ ? "Use this for extra non-auth headers."
+ : "Use this for non-auth headers not covered by the API key above."
+ return (
+ (
+
+ Additional headers
+
+
+
+
+ Optional JSON object of extra static headers sent on every request.
+ {` ${nonAuthClause} `}
+ Saving new JSON replaces the saved value.
+
+
+
+ )}
+ />
+ )
+}
+
+/**
+ * Collapsible "Advanced" section. Contains, in order, the passthrough toggle
+ * followed by the additional-headers JSON field. The passthrough toggle is
+ * hidden in the create wizard for litellm/ollama (see {@link isPassthroughVisible});
+ * on the edit dialog it is always shown. Open/close state is owned by the
+ * caller so error-driven auto-open (for the headers JSON) can force it open.
+ */
+export function AdvancedSection({
+ form,
+ type,
+ open,
+ onOpenChange,
+ surface,
+}: {
+ form: UseFormReturn
+ type: CustomProviderType
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ surface: CustomProviderSurface
+}) {
+ return (
+
+
+
+ Advanced
+
+
+
+ {isPassthroughVisible(type, surface) ? (
+
+ ) : null}
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/organization/custom-provider-form.ts b/frontend/src/components/organization/custom-provider-form.ts
new file mode 100644
index 0000000000..f95ddd6c69
--- /dev/null
+++ b/frontend/src/components/organization/custom-provider-form.ts
@@ -0,0 +1,267 @@
+import { z } from "zod"
+import type {
+ AgentCustomProviderCreate,
+ AgentCustomProviderRead,
+ AgentCustomProviderUpdate,
+ CustomProviderType,
+} from "@/client"
+
+/**
+ * Ordered list of selectable custom provider types shown in the wizard picker.
+ */
+export const CUSTOM_PROVIDER_TYPES: readonly CustomProviderType[] = [
+ "generic_openai_compatible",
+ "litellm",
+ "ollama",
+] as const
+
+/**
+ * Static presentation metadata for a custom provider type.
+ */
+export interface CustomProviderTypeOption {
+ value: CustomProviderType
+ label: string
+ description: string
+ /** Icon id understood by {@link ProviderIcon}. */
+ iconId: string
+}
+
+const CUSTOM_PROVIDER_TYPE_OPTIONS: Record<
+ CustomProviderType,
+ CustomProviderTypeOption
+> = {
+ generic_openai_compatible: {
+ value: "generic_openai_compatible",
+ label: "OpenAI-compatible",
+ description:
+ "Any endpoint that speaks the OpenAI API, such as vLLM or a self-hosted gateway.",
+ iconId: "custom",
+ },
+ litellm: {
+ value: "litellm",
+ label: "LiteLLM",
+ description:
+ "A LiteLLM proxy. Point at the proxy base URL and Tracecat forwards requests through it.",
+ iconId: "litellm",
+ },
+ ollama: {
+ value: "ollama",
+ label: "Ollama",
+ description:
+ "A local or remote Ollama server. No API key required; models are discovered from the gateway.",
+ iconId: "ollama",
+ },
+}
+
+/**
+ * Return the presentation metadata for a custom provider type.
+ */
+export function getCustomProviderTypeOption(
+ type: CustomProviderType
+): CustomProviderTypeOption {
+ return CUSTOM_PROVIDER_TYPE_OPTIONS[type]
+}
+
+/**
+ * Human-readable label for a custom provider type.
+ */
+export function getCustomProviderTypeLabel(
+ type: CustomProviderType | null | undefined
+): string {
+ if (!type) {
+ return "Custom"
+ }
+ return CUSTOM_PROVIDER_TYPE_OPTIONS[type]?.label ?? "Custom"
+}
+
+/**
+ * Derive the {@link ProviderIcon} id from the stored provider `type` field.
+ *
+ * This replaces the old name/URL substring heuristic: the icon now reflects the
+ * persisted type rather than guessing from the display name or base URL.
+ */
+export function getCustomProviderIconId(
+ type: CustomProviderType | null | undefined
+): string {
+ if (!type) {
+ return "custom"
+ }
+ return CUSTOM_PROVIDER_TYPE_OPTIONS[type]?.iconId ?? "custom"
+}
+
+/**
+ * Whether the credential fields (API key, auth header) apply to this type.
+ *
+ * Ollama needs no credentials; the backend injects a placeholder key.
+ */
+export function typeSupportsCredentials(type: CustomProviderType): boolean {
+ return type !== "ollama"
+}
+
+/**
+ * Whether the passthrough control is user-configurable for this type.
+ *
+ * Passthrough is a free toggle for every type now; the wizard prefills a
+ * per-type default but the user can freely change it.
+ */
+export function typeSupportsPassthrough(_type: CustomProviderType): boolean {
+ return true
+}
+
+/**
+ * The prefilled passthrough default when a type is first picked in the wizard.
+ *
+ * LiteLLM and Ollama default on (both natively serve the Anthropic passthrough
+ * endpoint); generic defaults off. Prefill only, freely changeable afterward.
+ */
+export function typeDefaultPassthrough(type: CustomProviderType): boolean {
+ return type === "litellm" || type === "ollama"
+}
+
+export const customProviderSchema = z
+ .object({
+ type: z.enum(["generic_openai_compatible", "litellm", "ollama"]),
+ displayName: z.string().trim().min(1, "Name is required"),
+ baseUrl: z.union([z.string().url(), z.literal(""), z.undefined()]),
+ apiKeyHeader: z.string().trim().optional(),
+ apiKey: z.string().optional(),
+ customHeadersJson: z.string().optional(),
+ passthrough: z.boolean(),
+ })
+ .superRefine((value, ctx) => {
+ const raw = value.customHeadersJson?.trim()
+ if (!raw) {
+ return
+ }
+
+ try {
+ const parsed = JSON.parse(raw)
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Custom headers must be a JSON object.",
+ path: ["customHeadersJson"],
+ })
+ return
+ }
+ for (const [key, headerValue] of Object.entries(parsed)) {
+ if (typeof key !== "string" || typeof headerValue !== "string") {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Custom headers must map string keys to string values.",
+ path: ["customHeadersJson"],
+ })
+ return
+ }
+ }
+ } catch {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "Custom headers must be valid JSON.",
+ path: ["customHeadersJson"],
+ })
+ }
+ })
+
+export type CustomProviderFormValues = z.infer
+
+export const DEFAULT_CUSTOM_PROVIDER_VALUES: CustomProviderFormValues = {
+ type: "generic_openai_compatible",
+ displayName: "",
+ baseUrl: "",
+ apiKeyHeader: "",
+ apiKey: "",
+ customHeadersJson: "",
+ passthrough: false,
+}
+
+/**
+ * Build the initial form values for the edit dialog from a saved provider.
+ */
+export function getProviderDialogDefaults(
+ provider: AgentCustomProviderRead | null
+): CustomProviderFormValues {
+ if (!provider) {
+ return DEFAULT_CUSTOM_PROVIDER_VALUES
+ }
+ return {
+ type: provider.type,
+ displayName: provider.display_name,
+ baseUrl: provider.base_url ?? "",
+ apiKeyHeader: provider.api_key_header ?? "",
+ apiKey: "",
+ customHeadersJson: "",
+ passthrough: provider.passthrough,
+ }
+}
+
+function normalizeOptional(value: string | null | undefined): string | null {
+ if (value == null) {
+ return null
+ }
+ const trimmed = value.trim()
+ return trimmed.length > 0 ? trimmed : null
+}
+
+function parseCustomHeaders(
+ value: string | null | undefined
+): Record | null {
+ const trimmed = value?.trim()
+ if (!trimmed) {
+ return null
+ }
+ return JSON.parse(trimmed) as Record
+}
+
+/**
+ * Build the create payload, applying type-aware field visibility so hidden
+ * credential fields never leak into the request (e.g. no key for Ollama).
+ * Passthrough is sent verbatim for all types.
+ */
+export function buildProviderCreatePayload(
+ values: CustomProviderFormValues
+): AgentCustomProviderCreate {
+ const supportsCredentials = typeSupportsCredentials(values.type)
+ return {
+ type: values.type,
+ display_name: values.displayName.trim(),
+ base_url: normalizeOptional(values.baseUrl),
+ api_key_header: supportsCredentials
+ ? normalizeOptional(values.apiKeyHeader)
+ : null,
+ api_key: supportsCredentials ? normalizeOptional(values.apiKey) : null,
+ custom_headers: parseCustomHeaders(values.customHeadersJson),
+ passthrough: values.passthrough,
+ }
+}
+
+/**
+ * Build the update payload, applying the same type-aware visibility rules.
+ */
+export function buildProviderUpdatePayload(
+ values: CustomProviderFormValues
+): AgentCustomProviderUpdate {
+ const supportsCredentials = typeSupportsCredentials(values.type)
+ const payload: AgentCustomProviderUpdate = {
+ type: values.type,
+ display_name: values.displayName.trim(),
+ base_url: normalizeOptional(values.baseUrl),
+ api_key_header: supportsCredentials
+ ? normalizeOptional(values.apiKeyHeader)
+ : null,
+ passthrough: values.passthrough,
+ }
+
+ if (supportsCredentials) {
+ const apiKey = normalizeOptional(values.apiKey)
+ if (apiKey) {
+ payload.api_key = apiKey
+ }
+ }
+ const customHeaders = parseCustomHeaders(values.customHeadersJson)
+ if (customHeaders) {
+ payload.custom_headers = customHeaders
+ }
+
+ return payload
+}
diff --git a/frontend/src/components/organization/custom-provider-wizard.tsx b/frontend/src/components/organization/custom-provider-wizard.tsx
new file mode 100644
index 0000000000..e332c57247
--- /dev/null
+++ b/frontend/src/components/organization/custom-provider-wizard.tsx
@@ -0,0 +1,481 @@
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useMutation, useQueryClient } from "@tanstack/react-query"
+import { ArrowLeft, Check, Loader2 } from "lucide-react"
+import { useEffect, useState } from "react"
+import { useForm } from "react-hook-form"
+import {
+ type AgentCatalogRead,
+ type AgentCustomProviderRead,
+ type ApiError,
+ type CustomProviderType,
+ createCustomProvider,
+ deleteCustomProvider,
+ listCatalog,
+ refreshCustomProviderCatalog,
+ validateCustomProviderConnection,
+} from "@/client"
+import { ProviderIcon } from "@/components/icons"
+import {
+ AdvancedSection,
+ BaseUrlField,
+ CredentialFields,
+} from "@/components/organization/custom-provider-fields"
+import {
+ buildProviderCreatePayload,
+ CUSTOM_PROVIDER_TYPES,
+ type CustomProviderFormValues,
+ customProviderSchema,
+ DEFAULT_CUSTOM_PROVIDER_VALUES,
+ getCustomProviderTypeOption,
+ typeDefaultPassthrough,
+} from "@/components/organization/custom-provider-form"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { toast } from "@/components/ui/use-toast"
+import { getApiErrorDetail } from "@/lib/errors"
+import { cn } from "@/lib/utils"
+
+type WizardStep = "type" | "config" | "test"
+
+const DISCOVERY_PREVIEW_LIMIT = 12
+const DISCOVERY_POLL_ATTEMPTS = 8
+const DISCOVERY_POLL_INTERVAL_MS = 1500
+
+/**
+ * Fetch every catalog entry belonging to a custom provider. The list endpoint
+ * has no custom-provider filter, so we page through and filter client-side.
+ */
+async function fetchProviderModels(
+ providerId: string
+): Promise {
+ const items: AgentCatalogRead[] = []
+ let cursor: string | undefined
+ do {
+ const response = await listCatalog({ cursor, limit: 100 })
+ for (const entry of response.items) {
+ if (entry.custom_provider_id === providerId) {
+ items.push(entry)
+ }
+ }
+ cursor = response.next_cursor ?? undefined
+ } while (cursor)
+ return items
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+/**
+ * Step 1 type picker. Renders one selectable card per provider type.
+ */
+function TypeStep({
+ value,
+ onChange,
+}: {
+ value: CustomProviderType
+ onChange: (type: CustomProviderType) => void
+}) {
+ return (
+
+ {CUSTOM_PROVIDER_TYPES.map((type) => {
+ const option = getCustomProviderTypeOption(type)
+ const selected = value === type
+ return (
+
onChange(type)}
+ aria-pressed={selected}
+ className={cn(
+ "flex w-full items-start gap-3 rounded-lg border p-4 text-left transition-colors",
+ selected
+ ? "border-foreground/40 bg-muted/50"
+ : "border-border hover:bg-muted/30"
+ )}
+ >
+
+
+
+ {option.label}
+ {selected ? : null}
+
+
+ {option.description}
+
+
+
+ )
+ })}
+
+ )
+}
+
+/**
+ * Multi-step wizard for creating a custom provider.
+ *
+ * Steps: (1) pick type, (2) type-aware base URL + credentials, (3) live
+ * connection test followed by a discovered-models preview. The provider is only
+ * persisted once the connection test succeeds; Finish is gated on that success.
+ */
+export function CustomProviderWizard({
+ open,
+ onOpenChange,
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}) {
+ const queryClient = useQueryClient()
+ const form = useForm({
+ resolver: zodResolver(customProviderSchema),
+ mode: "onBlur",
+ defaultValues: DEFAULT_CUSTOM_PROVIDER_VALUES,
+ })
+
+ const [step, setStep] = useState("type")
+ const [advancedOpen, setAdvancedOpen] = useState(false)
+ const [connectionValid, setConnectionValid] = useState(false)
+ const [createdProvider, setCreatedProvider] =
+ useState(null)
+ const [discoveredModels, setDiscoveredModels] = useState<
+ AgentCatalogRead[] | null
+ >(null)
+
+ const selectedType = form.watch("type")
+
+ useEffect(() => {
+ if (!open) {
+ return
+ }
+ form.reset(DEFAULT_CUSTOM_PROVIDER_VALUES)
+ setStep("type")
+ setAdvancedOpen(false)
+ setConnectionValid(false)
+ setCreatedProvider(null)
+ setDiscoveredModels(null)
+ }, [open, form])
+
+ // Picking a type prefills its per-type passthrough default (litellm/ollama
+ // on, generic off). Prefill only, freely changeable afterward.
+ function handleTypeChange(type: CustomProviderType) {
+ form.setValue("type", type)
+ form.setValue("passthrough", typeDefaultPassthrough(type))
+ }
+
+ const testMutation = useMutation({
+ mutationFn: async (
+ values: CustomProviderFormValues
+ ): Promise<{
+ provider: AgentCustomProviderRead
+ models: AgentCatalogRead[]
+ }> => {
+ const payload = buildProviderCreatePayload(values)
+ const result = await validateCustomProviderConnection({
+ requestBody: payload,
+ })
+ if (!result.valid) {
+ throw new Error("The provider did not respond successfully.")
+ }
+ // Re-testing must not orphan the provider created by a prior attempt.
+ if (createdProvider) {
+ await deleteCustomProvider({ providerId: createdProvider.id }).catch(
+ () => {}
+ )
+ setCreatedProvider(null)
+ }
+ const provider = await createCustomProvider({ requestBody: payload })
+ await refreshCustomProviderCatalog({ providerId: provider.id })
+ // Discovery hydrates lazily; poll a bounded number of times for a preview.
+ let models: AgentCatalogRead[] = []
+ for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
+ models = await fetchProviderModels(provider.id)
+ if (models.length > 0) {
+ break
+ }
+ await delay(DISCOVERY_POLL_INTERVAL_MS)
+ }
+ return { provider, models }
+ },
+ onSuccess: ({ provider, models }) => {
+ setConnectionValid(true)
+ setCreatedProvider(provider)
+ setDiscoveredModels(models)
+ queryClient.invalidateQueries({
+ queryKey: ["organization", "agent-providers"],
+ })
+ queryClient.invalidateQueries({
+ queryKey: ["organization", "agent-catalog"],
+ })
+ // Discovery auto-enables every discovered model org-wide, so refresh the
+ // access rows too or the settings view shows them as disabled.
+ queryClient.invalidateQueries({
+ queryKey: ["organization", "agent-model-access"],
+ })
+ },
+ onError: (error: unknown) => {
+ setConnectionValid(false)
+ const detail =
+ error instanceof Error
+ ? (getApiErrorDetail(error as ApiError) ?? error.message)
+ : "Unable to validate the custom source."
+ toast({
+ title: "Connection test failed",
+ description: detail,
+ variant: "destructive",
+ })
+ },
+ })
+
+ async function handleNextFromConfig() {
+ const valid = await form.trigger([
+ "type",
+ "displayName",
+ "baseUrl",
+ "apiKeyHeader",
+ "apiKey",
+ "customHeadersJson",
+ "passthrough",
+ ])
+ if (!valid) {
+ if (form.formState.errors.customHeadersJson) {
+ setAdvancedOpen(true)
+ }
+ return
+ }
+ setStep("test")
+ }
+
+ async function handleTestConnection() {
+ const valid = await form.trigger()
+ if (!valid) {
+ if (form.formState.errors.customHeadersJson) {
+ setAdvancedOpen(true)
+ }
+ return
+ }
+ await testMutation.mutateAsync(form.getValues()).catch(() => {})
+ }
+
+ function handleFinish() {
+ if (!connectionValid) {
+ return
+ }
+ onOpenChange(false)
+ toast({
+ title: "Custom source created",
+ description: createdProvider
+ ? `Created ${createdProvider.display_name}.`
+ : "Created the custom source.",
+ })
+ }
+
+ const previewModels =
+ discoveredModels?.slice(0, DISCOVERY_PREVIEW_LIMIT) ?? []
+ const hiddenModelCount = Math.max(
+ 0,
+ (discoveredModels?.length ?? 0) - previewModels.length
+ )
+
+ return (
+
+
+
+ Add custom source
+
+ {step === "type"
+ ? "Choose the kind of provider you want to connect."
+ : step === "config"
+ ? "Configure the endpoint and credentials."
+ : "Test the connection and preview discovered models."}
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/components/organization/org-settings-agent.tsx b/frontend/src/components/organization/org-settings-agent.tsx
index fc527dde3e..cd26e32c8e 100644
--- a/frontend/src/components/organization/org-settings-agent.tsx
+++ b/frontend/src/components/organization/org-settings-agent.tsx
@@ -2,15 +2,13 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
-import { ChevronDown, Loader2, MoreVertical } from "lucide-react"
+import { Loader2, MoreHorizontal, MoreVertical } from "lucide-react"
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import {
type AgentCatalogRead,
- type AgentCustomProviderCreate,
type AgentCustomProviderRead,
- type AgentCustomProviderUpdate,
type AgentModelAccessRead,
type ApiError,
type AzureAICatalogCreate,
@@ -18,7 +16,6 @@ import {
agentDeleteProviderCredentials,
type BedrockCatalogCreate,
createCatalogEntry,
- createCustomProvider,
deleteCatalogEntry,
deleteCustomProvider,
disableModel,
@@ -28,13 +25,17 @@ import {
listEnabledModels,
refreshCustomProviderCatalog,
updateCatalogEntry,
- updateCustomProvider,
type VertexAICatalogCreate,
- validateCustomProviderConnection,
} from "@/client"
import { ProviderIcon } from "@/components/icons"
import { CenteredSpinner } from "@/components/loading/spinner"
import { AlertNotification } from "@/components/notifications"
+import { CustomProviderDialog } from "@/components/organization/custom-provider-dialog"
+import {
+ getCustomProviderIconId,
+ getCustomProviderTypeLabel,
+} from "@/components/organization/custom-provider-form"
+import { CustomProviderWizard } from "@/components/organization/custom-provider-wizard"
import { AgentCredentialsDialog } from "@/components/organization/org-agent-credentials-dialog"
import {
RbacListContainer,
@@ -50,20 +51,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
-import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card"
-import {
- Collapsible,
- CollapsibleContent,
- CollapsibleTrigger,
-} from "@/components/ui/collapsible"
import {
Dialog,
DialogContent,
@@ -97,7 +85,6 @@ import {
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
-import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/use-toast"
import { useDebounce } from "@/hooks"
import { useEntitlements } from "@/hooks/use-entitlements"
@@ -111,61 +98,6 @@ import { cn } from "@/lib/utils"
const CURSOR_PAGE_SIZE = 100
-const customProviderSchema = z
- .object({
- displayName: z.string().trim().min(1, "Name is required"),
- baseUrl: z.union([z.string().url(), z.literal(""), z.undefined()]),
- apiKeyHeader: z.string().trim().optional(),
- apiKey: z.string().optional(),
- customHeadersJson: z.string().optional(),
- passthrough: z.boolean(),
- })
- .superRefine((value, ctx) => {
- const raw = value.customHeadersJson?.trim()
- if (!raw) {
- return
- }
-
- try {
- const parsed = JSON.parse(raw)
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Custom headers must be a JSON object.",
- path: ["customHeadersJson"],
- })
- return
- }
- for (const [key, headerValue] of Object.entries(parsed)) {
- if (typeof key !== "string" || typeof headerValue !== "string") {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Custom headers must map string keys to string values.",
- path: ["customHeadersJson"],
- })
- return
- }
- }
- } catch {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: "Custom headers must be valid JSON.",
- path: ["customHeadersJson"],
- })
- }
- })
-
-type CustomProviderFormValues = z.infer
-
-const DEFAULT_CUSTOM_PROVIDER_VALUES: CustomProviderFormValues = {
- displayName: "",
- baseUrl: "",
- apiKeyHeader: "",
- apiKey: "",
- customHeadersJson: "",
- passthrough: false,
-}
-
const CLOUD_CATALOG_PROVIDERS = [
"bedrock",
"azure_openai",
@@ -453,7 +385,7 @@ interface CustomSourceRead {
last_error?: string | null
}
-interface CustomSourceCard extends CustomSourceRead {
+export interface CustomSourceCard extends CustomSourceRead {
models: ModelCatalogEntry[]
provider: AgentCustomProviderRead
}
@@ -476,24 +408,6 @@ function toModelSelection(
}
}
-function normalizeOptional(value: string | null | undefined): string | null {
- if (value == null) {
- return null
- }
- const trimmed = value.trim()
- return trimmed.length > 0 ? trimmed : null
-}
-
-function parseCustomHeaders(
- value: string | null | undefined
-): Record | null {
- const trimmed = value?.trim()
- if (!trimmed) {
- return null
- }
- return JSON.parse(trimmed) as Record
-}
-
function formatDateTime(value?: string | null): string {
if (!value) {
return "Never"
@@ -550,42 +464,6 @@ function getProviderIconId(provider?: string | null): string {
}
}
-function normalizeSourceName(value: string): string {
- return value
- .trim()
- .toLowerCase()
- .replace(/[\s_-]+/g, "")
-}
-
-function getCustomSourceTypeLabel(type: string): string {
- switch (type) {
- case "manual_custom":
- return "Manual custom"
- case "openai_compatible_gateway":
- return "OpenAI-compatible"
- default:
- return type.replaceAll("_", " ")
- }
-}
-
-function getCustomSourceFlavorLabel(flavor?: string | null): string | null {
- if (!flavor) {
- return null
- }
- switch (flavor) {
- case "generic_openai_compatible":
- return "Generic OpenAI-compatible"
- case "ollama":
- return "Ollama"
- case "vllm":
- return "vLLM"
- case "manual":
- return "Manual"
- default:
- return flavor.replaceAll("_", " ")
- }
-}
-
function getMetadataNumber(metadata: unknown, key: string): number | null {
if (!metadata || typeof metadata !== "object") {
return null
@@ -650,37 +528,6 @@ function getModelSourceLabel(
)
}
-function getCustomSourceIconId(
- source: Pick
-): string {
- switch (source.flavor) {
- case "ollama":
- return "ollama"
- case "vllm":
- return "vllm"
- case "manual":
- return "manual-custom-source"
- default:
- return source.type === "manual_custom" ? "manual-custom-source" : "custom"
- }
-}
-
-function inferCustomSourceFlavor(
- provider: Pick
-): string | null {
- const candidates = [provider.display_name, provider.base_url ?? ""].map(
- normalizeSourceName
- )
-
- if (candidates.some((candidate) => candidate.includes("ollama"))) {
- return "ollama"
- }
- if (candidates.some((candidate) => candidate.includes("vllm"))) {
- return "vllm"
- }
- return null
-}
-
function canEnableBuiltInCatalogModel(model: BuiltInCatalogEntry): boolean {
return (
model.enabled ||
@@ -713,57 +560,6 @@ function ProviderMetaPill({
)
}
-function getProviderDialogDefaults(
- provider: AgentCustomProviderRead | null
-): CustomProviderFormValues {
- if (!provider) {
- return DEFAULT_CUSTOM_PROVIDER_VALUES
- }
- return {
- displayName: provider.display_name,
- baseUrl: provider.base_url ?? "",
- apiKeyHeader: provider.api_key_header ?? "",
- apiKey: "",
- customHeadersJson: "",
- passthrough: provider.passthrough,
- }
-}
-
-function buildProviderCreatePayload(
- values: CustomProviderFormValues
-): AgentCustomProviderCreate {
- return {
- display_name: values.displayName.trim(),
- base_url: normalizeOptional(values.baseUrl),
- api_key_header: normalizeOptional(values.apiKeyHeader),
- api_key: normalizeOptional(values.apiKey),
- custom_headers: parseCustomHeaders(values.customHeadersJson),
- passthrough: values.passthrough,
- }
-}
-
-function buildProviderUpdatePayload(
- values: CustomProviderFormValues
-): AgentCustomProviderUpdate {
- const payload: AgentCustomProviderUpdate = {
- display_name: values.displayName.trim(),
- base_url: normalizeOptional(values.baseUrl),
- api_key_header: normalizeOptional(values.apiKeyHeader),
- passthrough: values.passthrough,
- }
-
- const apiKey = normalizeOptional(values.apiKey)
- if (apiKey) {
- payload.api_key = apiKey
- }
- const customHeaders = parseCustomHeaders(values.customHeadersJson)
- if (customHeaders) {
- payload.custom_headers = customHeaders
- }
-
- return payload
-}
-
async function fetchAllProviders(): Promise {
const items: AgentCustomProviderRead[] = []
let cursor: string | undefined
@@ -812,289 +608,6 @@ async function fetchAllEnabledModels(): Promise {
return items
}
-function CustomProviderDialog({
- provider,
- open,
- onOpenChange,
-}: {
- provider: AgentCustomProviderRead | null
- open: boolean
- onOpenChange: (open: boolean) => void
-}) {
- const queryClient = useQueryClient()
- const form = useForm({
- resolver: zodResolver(customProviderSchema),
- mode: "onBlur",
- defaultValues: getProviderDialogDefaults(provider),
- })
-
- const [advancedOpen, setAdvancedOpen] = useState(false)
- const hasCustomHeadersError = !!form.formState.errors.customHeadersJson
-
- useEffect(() => {
- form.reset(getProviderDialogDefaults(provider))
- setAdvancedOpen(false)
- }, [form, provider, open])
-
- useEffect(() => {
- if (hasCustomHeadersError) {
- setAdvancedOpen(true)
- }
- }, [hasCustomHeadersError])
-
- const saveMutation = useMutation({
- mutationFn: async (values: CustomProviderFormValues) => {
- if (provider) {
- return await updateCustomProvider({
- providerId: provider.id,
- requestBody: buildProviderUpdatePayload(values),
- })
- }
- return await createCustomProvider({
- requestBody: buildProviderCreatePayload(values),
- })
- },
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: ["organization", "agent-providers"],
- })
- onOpenChange(false)
- toast({
- title: provider ? "Custom source updated" : "Custom source created",
- description: provider
- ? "Saved the custom source configuration."
- : "Created the custom source.",
- })
- },
- onError: (error: ApiError) => {
- toast({
- title: provider ? "Update failed" : "Create failed",
- description:
- getApiErrorDetail(error) ?? "Unable to save the custom source.",
- variant: "destructive",
- })
- },
- })
-
- const validateMutation = useMutation({
- mutationFn: async (values: CustomProviderFormValues) =>
- await validateCustomProviderConnection({
- requestBody: buildProviderCreatePayload(values),
- }),
- onSuccess: (result) => {
- toast({
- title: result.valid ? "Connection looks good" : "Connection failed",
- description: result.valid
- ? "The provider responded successfully."
- : "The provider did not respond successfully.",
- variant: result.valid ? "default" : "destructive",
- })
- },
- onError: (error: ApiError) => {
- toast({
- title: "Connection test failed",
- description:
- getApiErrorDetail(error) ?? "Unable to validate the custom source.",
- variant: "destructive",
- })
- },
- })
-
- async function handleValidate() {
- const valid = await form.trigger()
- if (!valid) {
- if (form.formState.errors.customHeadersJson) {
- setAdvancedOpen(true)
- }
- return
- }
- await validateMutation.mutateAsync(form.getValues())
- }
-
- async function handleSubmit(values: CustomProviderFormValues) {
- await saveMutation.mutateAsync(values)
- }
-
- return (
-
-
-
-
- {provider ? "Edit custom source" : "Add custom source"}
-
-
- Configure a user-defined OpenAI-compatible endpoint. Discovery reads
- the source's /models endpoint.
-
-
-
-
-
-
-
- )
-}
-
function CustomSourceModelRow({
disabled,
model,
@@ -1106,38 +619,23 @@ function CustomSourceModelRow({
}) {
const title = getCustomSourceModelTitle(model)
const showModelName = title !== model.model_name
- const contextLabel = getModelContextLabel(model)
- const outputLabel = getModelOutputLabel(model)
const modeLabel = getModelModeLabel(model)
- const sourceLabel = getModelSourceLabel(model)
const detailParts = [
- sourceLabel,
showModelName ? model.model_name : null,
modeLabel !== "n/a" ? modeLabel : null,
].filter(Boolean)
- const capabilityParts = [
- contextLabel !== "n/a" ? `${contextLabel} ctx` : null,
- outputLabel !== "n/a" ? `${outputLabel} out` : null,
- ].filter(Boolean)
return (
{title}
-
{model.model_provider}
- {model.base_url ?
Custom URL : null}
{detailParts.length ? (
{detailParts.join(" · ")}
) : null}
- {capabilityParts.length ? (
-
- {capabilityParts.join(" · ")}
-
- ) : null}
void
+ onEdit: (provider: AgentCustomProviderRead) => void
+ onRefresh: (source: CustomSourceCard) => void
+ onDelete: (provider: AgentCustomProviderRead) => void
+ onToggleModel: (model: ModelCatalogEntry) => Promise
+}) {
+ const totalModels = source.models.length
+ const enabledModels = source.models.filter((model) => model.enabled).length
+ const typeLabel = getCustomProviderTypeLabel(source.provider.type)
+ const subtitleParts = [
+ typeLabel,
+ formatStatus(source.discovery_status),
+ source.base_url ?? null,
+ ].filter(Boolean)
+
+ return (
+
+ {source.provider.passthrough ? (
+ Passthrough
+ ) : null}
+ 0}>
+ {totalModels ? `${enabledModels} enabled` : "No models discovered"}
+
+
+
+
+
+
+
+
+ onEdit(source.provider)}>
+ Edit
+
+ onRefresh(source)}>
+ Refresh
+
+ onDelete(source.provider)}
+ >
+ Delete
+
+
+
+
+ }
+ badges={null}
+ icon={
+
+ }
+ isExpanded={isExpanded}
+ onExpandedChange={onExpandedChange}
+ reserveExpandSpace
+ subtitle={subtitleParts.join(" · ")}
+ title={source.display_name}
+ >
+
+
+
Last refreshed: {formatDateTime(source.last_refreshed_at)}
+ {source.base_url ? (
+
+ Base URL: {source.base_url}
+
+ ) : null}
+
+
+ {source.last_error ? (
+
+ {source.last_error}
+
+ ) : null}
+
+ {!source.base_url ? (
+
+ Set a base URL before refreshing this custom endpoint.
+
+ ) : null}
+
+ {source.models.length ? (
+
+ {!agentAddonsEnabled ? (
+
+ Upgrade to enable or disable specific source-backed models for
+ presets and defaults.
+
+ ) : null}
+ {source.models.map((model) => (
+
+ ))}
+
+ ) : (
+
+ Refresh this source, then enable the entries you want available to
+ presets and defaults.
+
+ )}
+
+
+ )
+}
+
interface CloudCatalogModelDialogProps {
provider: CloudCatalogProvider | null
entry: AgentCatalogRead | null
@@ -1893,8 +1532,10 @@ export function OrgSettingsAgentForm() {
const [deletingProvider, setDeletingProvider] =
useState(null)
const [expandedProvider, setExpandedProvider] = useState(null)
- const [customProviderDialogOpen, setCustomProviderDialogOpen] =
- useState(false)
+ const [expandedCustomSource, setExpandedCustomSource] = useState<
+ string | null
+ >(null)
+ const [wizardOpen, setWizardOpen] = useState(false)
const [cloudModelDialog, setCloudModelDialog] = useState<{
provider: CloudCatalogProvider
entry: AgentCatalogRead | null
@@ -2120,7 +1761,6 @@ export function OrgSettingsAgentForm() {
left.display_name.localeCompare(right.display_name)
)
.map((provider): CustomSourceCard => {
- const sourceFlavor = inferCustomSourceFlavor(provider)
const providerEntries = [
...(catalogByProviderId.get(provider.id) ?? []),
]
@@ -2144,7 +1784,7 @@ export function OrgSettingsAgentForm() {
return {
id: provider.id,
type: "openai_compatible_gateway",
- flavor: sourceFlavor,
+ flavor: null,
display_name: provider.display_name,
base_url: provider.base_url,
api_key_configured: false,
@@ -2722,13 +2362,7 @@ export function OrgSettingsAgentForm() {
gateways.
- {
- setEditingProvider(null)
- setCustomProviderDialogOpen(true)
- }}
- variant="outline"
- >
+ setWizardOpen(true)} variant="outline">
Add custom source
@@ -2736,133 +2370,35 @@ export function OrgSettingsAgentForm() {
{customSourcesSectionLoading ? (
) : customSourceCards.length ? (
-
+
{customSourceCards.map((source) => (
-
-
-
-
-
-
- {source.display_name}
-
- {getCustomSourceTypeLabel(source.type)}
- {getCustomSourceFlavorLabel(source.flavor)
- ? ` · ${getCustomSourceFlavorLabel(source.flavor)}`
- : ""}
- {` · ${formatStatus(source.discovery_status)}`}
-
-
-
-
- {
- setEditingProvider(source.provider)
- setCustomProviderDialogOpen(true)
- }}
- size="sm"
- variant="outline"
- >
- Edit
-
- {
- void handleRefreshCustomProvider(source)
- }}
- size="sm"
- variant="outline"
- >
- Refresh
-
- setDeletingProvider(source.provider)}
- size="sm"
- variant="outline"
- >
- Delete
-
-
-
-
-
- Last refreshed: {formatDateTime(source.last_refreshed_at)}
-
- {source.base_url ? (
-
- Base URL:{" "}
- {source.base_url}
-
- ) : null}
-
-
-
- {source.last_error ? (
-
- {source.last_error}
-
- ) : null}
-
- {!source.base_url ? (
-
- Set a base URL before refreshing this custom endpoint.
-
- ) : null}
-
- {source.models.length ? (
- <>
- {!agentAddonsEnabled ? (
-
- Upgrade to enable or disable specific source-backed
- models for presets and defaults.
-
- ) : null}
- {source.models.map((model) => (
-
- ))}
- >
- ) : (
-
- Refresh this source, then enable the entries you want
- available to presets and defaults.
-
- )}
-
-
+ {
+ setExpandedCustomSource(expanded ? source.id : null)
+ }}
+ onRefresh={(target) => {
+ void handleRefreshCustomProvider(target)
+ }}
+ onToggleModel={handleModelToggle}
+ source={source}
+ />
))}
-
+
) : (
-
-
-
-
No custom sources yet
-
- Use custom sources only when you need a user-defined endpoint
- beyond the platform provider cards and shared platform
- catalog.
-
-
-
-
+
+ No custom sources yet. Add one when you need a user-defined endpoint
+ beyond the platform provider cards and shared platform catalog.
+
)}
@@ -2874,16 +2410,19 @@ export function OrgSettingsAgentForm() {
providerConfigured={selectedCredentialsConfigured}
/>
- {
- setCustomProviderDialogOpen(open)
- if (!open) {
- setEditingProvider(null)
- }
- }}
- />
+
+
+ {editingProvider ? (
+ {
+ if (!open) {
+ setEditingProvider(null)
+ }
+ }}
+ />
+ ) : null}
{
diff --git a/frontend/tests/create-agent-dialog.test.tsx b/frontend/tests/create-agent-dialog.test.tsx
index 8ea5a186cc..2c2ad78951 100644
--- a/frontend/tests/create-agent-dialog.test.tsx
+++ b/frontend/tests/create-agent-dialog.test.tsx
@@ -82,6 +82,7 @@ const customProviders = [
organization_id: "org-1",
display_name: "Custom",
base_url: "https://models.example.com/v1",
+ type: "generic_openai_compatible" as const,
passthrough: true,
api_key_header: "Authorization",
last_refreshed_at: null,
diff --git a/frontend/tests/custom-provider-form.test.tsx b/frontend/tests/custom-provider-form.test.tsx
new file mode 100644
index 0000000000..b796481096
--- /dev/null
+++ b/frontend/tests/custom-provider-form.test.tsx
@@ -0,0 +1,207 @@
+import { render, screen } from "@testing-library/react"
+import { useForm } from "react-hook-form"
+import type { AgentCustomProviderRead, CustomProviderType } from "@/client"
+import { AdvancedSection } from "@/components/organization/custom-provider-fields"
+import {
+ buildProviderCreatePayload,
+ buildProviderUpdatePayload,
+ type CustomProviderFormValues,
+ customProviderSchema,
+ DEFAULT_CUSTOM_PROVIDER_VALUES,
+ getCustomProviderIconId,
+ getProviderDialogDefaults,
+ typeDefaultPassthrough,
+ typeSupportsCredentials,
+ typeSupportsPassthrough,
+} from "@/components/organization/custom-provider-form"
+import { Form } from "@/components/ui/form"
+
+const ALL_TYPES: CustomProviderType[] = [
+ "generic_openai_compatible",
+ "litellm",
+ "ollama",
+]
+
+describe("custom provider type-aware helpers", () => {
+ it("hides credentials only for ollama", () => {
+ expect(typeSupportsCredentials("generic_openai_compatible")).toBe(true)
+ expect(typeSupportsCredentials("litellm")).toBe(true)
+ expect(typeSupportsCredentials("ollama")).toBe(false)
+ })
+
+ it("supports the passthrough toggle for every type", () => {
+ for (const type of ALL_TYPES) {
+ expect(typeSupportsPassthrough(type)).toBe(true)
+ }
+ })
+
+ it("prefills litellm and ollama passthrough on, generic off", () => {
+ expect(typeDefaultPassthrough("litellm")).toBe(true)
+ expect(typeDefaultPassthrough("ollama")).toBe(true)
+ expect(typeDefaultPassthrough("generic_openai_compatible")).toBe(false)
+ })
+
+ it("derives the icon from the stored type, not the name/url", () => {
+ expect(getCustomProviderIconId("ollama")).toBe("ollama")
+ // A provider named "ollama" but typed generic must NOT get the ollama icon.
+ expect(getCustomProviderIconId("generic_openai_compatible")).toBe("custom")
+ expect(getCustomProviderIconId("litellm")).toBe("litellm")
+ expect(getCustomProviderIconId(null)).toBe("custom")
+ })
+})
+
+describe("buildProviderCreatePayload", () => {
+ it("drops credentials for ollama but sends passthrough verbatim", () => {
+ const payload = buildProviderCreatePayload({
+ type: "ollama",
+ displayName: "Local Ollama",
+ baseUrl: "http://localhost:11434",
+ apiKeyHeader: "Authorization",
+ apiKey: "should-be-dropped",
+ customHeadersJson: "",
+ passthrough: true,
+ })
+
+ expect(payload.type).toBe("ollama")
+ expect(payload.api_key).toBeNull()
+ expect(payload.api_key_header).toBeNull()
+ expect(payload.passthrough).toBe(true)
+ })
+
+ it("sends the passthrough form value verbatim for every type", () => {
+ for (const type of ALL_TYPES) {
+ for (const passthrough of [true, false]) {
+ const payload = buildProviderCreatePayload({
+ ...DEFAULT_CUSTOM_PROVIDER_VALUES,
+ type,
+ displayName: "x",
+ passthrough,
+ })
+ expect(payload.passthrough).toBe(passthrough)
+ }
+ }
+ })
+})
+
+describe("buildProviderUpdatePayload", () => {
+ it("sends the passthrough form value verbatim for every type", () => {
+ for (const type of ALL_TYPES) {
+ for (const passthrough of [true, false]) {
+ const payload = buildProviderUpdatePayload({
+ ...DEFAULT_CUSTOM_PROVIDER_VALUES,
+ type,
+ displayName: "x",
+ passthrough,
+ })
+ expect(payload.passthrough).toBe(passthrough)
+ }
+ }
+ })
+})
+
+describe("customProviderSchema", () => {
+ it("accepts every type/passthrough combination", () => {
+ for (const type of ALL_TYPES) {
+ for (const passthrough of [true, false]) {
+ const result = customProviderSchema.safeParse({
+ ...DEFAULT_CUSTOM_PROVIDER_VALUES,
+ type,
+ displayName: "x",
+ passthrough,
+ })
+ expect(result.success).toBe(true)
+ }
+ }
+ })
+})
+
+function AdvancedHarness({
+ type,
+ surface = "edit",
+}: {
+ type: CustomProviderType
+ surface?: "wizard" | "edit"
+}) {
+ const form = useForm({
+ defaultValues: { ...DEFAULT_CUSTOM_PROVIDER_VALUES, type },
+ })
+ return (
+
+ {}}
+ surface={surface}
+ />
+
+ )
+}
+
+describe("AdvancedSection", () => {
+ it("renders the passthrough toggle inside Advanced for every type on the edit surface", () => {
+ for (const type of ALL_TYPES) {
+ const { unmount } = render( )
+ expect(screen.getByText("Passthrough mode")).toBeInTheDocument()
+ unmount()
+ }
+ })
+
+ it("hides the passthrough toggle on the wizard surface for litellm/ollama only", () => {
+ const hidden: CustomProviderType[] = ["litellm", "ollama"]
+ for (const type of hidden) {
+ const { unmount } = render(
+
+ )
+ expect(screen.queryByText("Passthrough mode")).not.toBeInTheDocument()
+ expect(screen.getByLabelText("Additional headers")).toBeInTheDocument()
+ unmount()
+ }
+
+ // Generic keeps the visible toggle in the wizard.
+ render(
+
+ )
+ expect(screen.getByText("Passthrough mode")).toBeInTheDocument()
+ })
+
+ it("drops the API-key mention from the headers copy for ollama", () => {
+ render( )
+ expect(screen.getByText(/extra non-auth headers/i)).toBeInTheDocument()
+ expect(
+ screen.queryByText(/not covered by the API key above/i)
+ ).not.toBeInTheDocument()
+ })
+
+ it("keeps the API-key mention in the headers copy for non-ollama types", () => {
+ render( )
+ expect(
+ screen.getByText(/not covered by the API key above/i)
+ ).toBeInTheDocument()
+ })
+})
+
+describe("getProviderDialogDefaults", () => {
+ it("carries the stored type and passthrough into edit defaults", () => {
+ const provider: AgentCustomProviderRead = {
+ id: "p1",
+ organization_id: "org-1",
+ display_name: "My Ollama",
+ base_url: "http://localhost:11434",
+ type: "ollama",
+ passthrough: true,
+ api_key_header: null,
+ last_refreshed_at: null,
+ }
+ const defaults = getProviderDialogDefaults(provider)
+ expect(defaults.type).toBe("ollama")
+ // Edit dialog shows the stored value with no type-driven mutation.
+ expect(defaults.passthrough).toBe(true)
+ })
+
+ it("defaults to generic for a new provider", () => {
+ expect(getProviderDialogDefaults(null).type).toBe(
+ "generic_openai_compatible"
+ )
+ })
+})
diff --git a/frontend/tests/custom-provider-wizard.test.tsx b/frontend/tests/custom-provider-wizard.test.tsx
new file mode 100644
index 0000000000..ffada6fca1
--- /dev/null
+++ b/frontend/tests/custom-provider-wizard.test.tsx
@@ -0,0 +1,461 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { render, screen, waitFor } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import type { ReactNode } from "react"
+import {
+ createCustomProvider,
+ listCatalog,
+ refreshCustomProviderCatalog,
+ updateCustomProvider,
+ validateCustomProviderConnection,
+} from "@/client"
+import { CustomProviderDialog } from "@/components/organization/custom-provider-dialog"
+import { CustomProviderWizard } from "@/components/organization/custom-provider-wizard"
+
+jest.mock("@/client", () => ({
+ validateCustomProviderConnection: jest.fn(),
+ createCustomProvider: jest.fn(),
+ refreshCustomProviderCatalog: jest.fn(),
+ updateCustomProvider: jest.fn(),
+ deleteCustomProvider: jest.fn(),
+ listCatalog: jest.fn(),
+}))
+
+jest.mock("@/components/ui/use-toast", () => ({
+ toast: jest.fn(),
+}))
+
+jest.mock("@/components/icons", () => ({
+ ProviderIcon: ({ providerId }: { providerId: string }) => (
+
+ ),
+}))
+
+// Render dialog content unconditionally so the form is queryable.
+jest.mock("@/components/ui/dialog", () => ({
+ Dialog: ({ open, children }: { open: boolean; children: ReactNode }) =>
+ open ? {children}
: null,
+ DialogContent: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ DialogDescription: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ DialogFooter: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ DialogHeader: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ DialogTitle: ({ children }: { children: ReactNode }) => {children} ,
+}))
+
+const mockValidate = jest.mocked(validateCustomProviderConnection)
+const mockCreate = jest.mocked(createCustomProvider)
+const mockRefresh = jest.mocked(refreshCustomProviderCatalog)
+const mockUpdate = jest.mocked(updateCustomProvider)
+const mockListCatalog = jest.mocked(listCatalog)
+
+function wrapper({ children }: { children: ReactNode }) {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ mutations: { retry: false },
+ queries: { retry: false },
+ },
+ })
+ return (
+ {children}
+ )
+}
+
+beforeEach(() => {
+ jest.clearAllMocks()
+})
+
+describe("CustomProviderWizard", () => {
+ it("gates Finish behind a successful connection test", async () => {
+ const user = userEvent.setup()
+ mockValidate.mockResolvedValue({ valid: true })
+ mockCreate.mockResolvedValue({
+ id: "prov-1",
+ organization_id: "org-1",
+ display_name: "Gateway",
+ base_url: "https://gw.example.com/v1",
+ type: "generic_openai_compatible",
+ passthrough: false,
+ api_key_header: null,
+ last_refreshed_at: null,
+ })
+ mockRefresh.mockResolvedValue(undefined)
+ mockListCatalog.mockResolvedValue({
+ items: [
+ {
+ id: "cat-1",
+ custom_provider_id: "prov-1",
+ organization_id: "org-1",
+ model_provider: "custom",
+ model_name: "gw-model-a",
+ model_metadata: {},
+ },
+ ],
+ next_cursor: null,
+ })
+
+ render( , {
+ wrapper,
+ })
+
+ // Step 1: continue with the default (generic) type.
+ await user.click(screen.getByRole("button", { name: "Continue" }))
+
+ // Step 2: fill name + base URL, then continue to the test step.
+ await user.type(screen.getByLabelText("Name"), "Gateway")
+ await user.type(
+ screen.getByLabelText("Base URL"),
+ "https://gw.example.com/v1"
+ )
+ await user.click(screen.getByRole("button", { name: "Continue" }))
+
+ // Step 3: Finish must be disabled until the connection is verified.
+ const finishButton = screen.getByRole("button", { name: "Finish" })
+ expect(finishButton).toBeDisabled()
+
+ await user.click(screen.getByRole("button", { name: "Test connection" }))
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Finish" })).toBeEnabled()
+ })
+ expect(mockValidate).toHaveBeenCalled()
+ expect(mockCreate).toHaveBeenCalled()
+ expect(mockRefresh).toHaveBeenCalledWith({ providerId: "prov-1" })
+ expect(screen.getByText("gw-model-a")).toBeInTheDocument()
+ })
+
+ it("invalidates the model-access query after a successful creation", async () => {
+ const user = userEvent.setup()
+ mockValidate.mockResolvedValue({ valid: true })
+ mockCreate.mockResolvedValue({
+ id: "prov-1",
+ organization_id: "org-1",
+ display_name: "Gateway",
+ base_url: "https://gw.example.com/v1",
+ type: "generic_openai_compatible",
+ passthrough: false,
+ api_key_header: null,
+ last_refreshed_at: null,
+ })
+ mockRefresh.mockResolvedValue(undefined)
+ mockListCatalog.mockResolvedValue({
+ items: [
+ {
+ id: "cat-1",
+ custom_provider_id: "prov-1",
+ organization_id: "org-1",
+ model_provider: "custom",
+ model_name: "gw-model-a",
+ model_metadata: {},
+ },
+ ],
+ next_cursor: null,
+ })
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ mutations: { retry: false },
+ queries: { retry: false },
+ },
+ })
+ const invalidateSpy = jest.spyOn(queryClient, "invalidateQueries")
+
+ render( , {
+ wrapper: ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ ),
+ })
+
+ await user.click(screen.getByRole("button", { name: "Continue" }))
+ await user.type(screen.getByLabelText("Name"), "Gateway")
+ await user.type(
+ screen.getByLabelText("Base URL"),
+ "https://gw.example.com/v1"
+ )
+ await user.click(screen.getByRole("button", { name: "Continue" }))
+ await user.click(screen.getByRole("button", { name: "Test connection" }))
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Finish" })).toBeEnabled()
+ })
+
+ // Discovery auto-enables the discovered models, so the settings view must
+ // refetch access rows to avoid rendering them as disabled.
+ expect(invalidateSpy).toHaveBeenCalledWith({
+ queryKey: ["organization", "agent-model-access"],
+ })
+ })
+
+ it("exposes the passthrough toggle inside Advanced for a generic provider", async () => {
+ const user = userEvent.setup()
+ render( , {
+ wrapper,
+ })
+
+ // Step 1: continue with the default (generic) type.
+ await user.click(screen.getByRole("button", { name: "Continue" }))
+
+ // The passthrough toggle lives inside the collapsed Advanced section, so it
+ // is not visible until the section is opened.
+ expect(screen.queryByText("Passthrough mode")).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole("button", { name: "Advanced" }))
+
+ expect(screen.getByText("Passthrough mode")).toBeInTheDocument()
+ expect(screen.getByLabelText("Additional headers")).toBeInTheDocument()
+ })
+
+ it("hides credentials and the passthrough toggle for ollama, even with Advanced expanded", async () => {
+ const user = userEvent.setup()
+ render( , {
+ wrapper,
+ })
+
+ // Pick the Ollama type card, then continue to the config step.
+ await user.click(screen.getByRole("button", { name: /Ollama/ }))
+ await user.click(screen.getByRole("button", { name: "Continue" }))
+
+ expect(screen.getByLabelText("Base URL")).toBeInTheDocument()
+ expect(screen.queryByLabelText("Auth value")).not.toBeInTheDocument()
+ expect(screen.queryByLabelText("Auth header")).not.toBeInTheDocument()
+
+ // The wizard hides passthrough for ollama; it stays silently prefilled on.
+ await user.click(screen.getByRole("button", { name: "Advanced" }))
+ expect(screen.queryByText("Passthrough mode")).not.toBeInTheDocument()
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument()
+ expect(screen.getByLabelText("Additional headers")).toBeInTheDocument()
+ })
+
+ it("hides the passthrough toggle for litellm, even with Advanced expanded", async () => {
+ const user = userEvent.setup()
+ render( , {
+ wrapper,
+ })
+
+ await user.click(screen.getByRole("button", { name: /LiteLLM/ }))
+ await user.click(screen.getByRole("button", { name: "Continue" }))
+ await user.click(screen.getByRole("button", { name: "Advanced" }))
+
+ expect(screen.queryByText("Passthrough mode")).not.toBeInTheDocument()
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument()
+ expect(screen.getByLabelText("Additional headers")).toBeInTheDocument()
+ })
+
+ it("still creates litellm with passthrough=true despite the hidden toggle", async () => {
+ const user = userEvent.setup()
+ mockValidate.mockResolvedValue({ valid: true })
+ mockCreate.mockResolvedValue({
+ id: "prov-litellm",
+ organization_id: "org-1",
+ display_name: "Proxy",
+ base_url: "http://localhost:4000",
+ type: "litellm",
+ passthrough: true,
+ api_key_header: null,
+ last_refreshed_at: null,
+ })
+ mockRefresh.mockResolvedValue(undefined)
+ mockListCatalog.mockResolvedValue({ items: [], next_cursor: null })
+
+ render( , {
+ wrapper,
+ })
+
+ await user.click(screen.getByRole("button", { name: /LiteLLM/ }))
+ await user.click(screen.getByRole("button", { name: "Continue" }))
+ await user.type(screen.getByLabelText("Name"), "Proxy")
+ await user.type(screen.getByLabelText("Base URL"), "http://localhost:4000")
+ await user.click(screen.getByRole("button", { name: "Continue" }))
+ await user.click(screen.getByRole("button", { name: "Test connection" }))
+
+ await waitFor(() => {
+ expect(mockCreate).toHaveBeenCalled()
+ })
+ const requestBody = mockCreate.mock.calls[0]?.[0]?.requestBody
+ expect(requestBody?.type).toBe("litellm")
+ expect(requestBody?.passthrough).toBe(true)
+ })
+
+ it("prefills the passthrough toggle off for a generic provider", async () => {
+ const user = userEvent.setup()
+ render( , {
+ wrapper,
+ })
+
+ await user.click(screen.getByRole("button", { name: "Continue" }))
+ await user.click(screen.getByRole("button", { name: "Advanced" }))
+
+ expect(screen.getByText("Passthrough mode")).toBeInTheDocument()
+ expect(screen.getByRole("switch")).not.toBeChecked()
+ })
+})
+
+describe("CustomProviderDialog (edit)", () => {
+ const ollamaProvider = {
+ id: "prov-ollama",
+ organization_id: "org-1",
+ display_name: "Local Ollama",
+ base_url: "http://localhost:11434",
+ type: "ollama" as const,
+ passthrough: false,
+ api_key_header: null,
+ last_refreshed_at: null,
+ }
+
+ const genericProvider = {
+ id: "prov-generic",
+ organization_id: "org-1",
+ display_name: "Gateway",
+ base_url: "https://gw.example.com/v1",
+ type: "generic_openai_compatible" as const,
+ passthrough: false,
+ api_key_header: null,
+ last_refreshed_at: null,
+ }
+
+ const litellmProvider = {
+ id: "prov-litellm",
+ organization_id: "org-1",
+ display_name: "Proxy",
+ base_url: "http://localhost:4000",
+ type: "litellm" as const,
+ passthrough: true,
+ api_key_header: null,
+ last_refreshed_at: null,
+ }
+
+ it("exposes the passthrough toggle inside Advanced for a litellm provider", async () => {
+ const user = userEvent.setup()
+ render(
+ ,
+ { wrapper }
+ )
+
+ // Unlike the wizard, the edit dialog always shows the toggle for litellm,
+ // reflecting the stored value (true here).
+ await user.click(screen.getByRole("button", { name: "Advanced" }))
+ expect(screen.getByText("Passthrough mode")).toBeInTheDocument()
+ expect(screen.getByRole("switch")).toBeChecked()
+ expect(screen.getByLabelText("Additional headers")).toBeInTheDocument()
+ })
+
+ it("hides credentials but exposes the passthrough toggle for an ollama provider", async () => {
+ const user = userEvent.setup()
+ render(
+ ,
+ { wrapper }
+ )
+
+ expect(screen.queryByLabelText("Auth value")).not.toBeInTheDocument()
+
+ // The passthrough toggle is present for Ollama and reflects the stored
+ // value (false here) with no type-driven mutation.
+ await user.click(screen.getByRole("button", { name: "Advanced" }))
+ expect(screen.getByText("Passthrough mode")).toBeInTheDocument()
+ expect(screen.getByRole("switch")).not.toBeChecked()
+ expect(screen.getByLabelText("Additional headers")).toBeInTheDocument()
+ })
+
+ it("exposes the passthrough toggle inside Advanced for a generic provider", async () => {
+ const user = userEvent.setup()
+ render(
+ ,
+ { wrapper }
+ )
+
+ // Collapsed by default, then revealed inside the Advanced section.
+ expect(screen.queryByText("Passthrough mode")).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole("button", { name: "Advanced" }))
+ expect(screen.getByText("Passthrough mode")).toBeInTheDocument()
+ expect(screen.getByLabelText("Additional headers")).toBeInTheDocument()
+ })
+
+ it("exposes an editable type selector on the edit path", () => {
+ render(
+ ,
+ { wrapper }
+ )
+
+ // The type control is a combobox (select) rather than a fixed label, so the
+ // type is editable when editing an existing provider.
+ const typeSelect = screen.getByRole("combobox")
+ expect(typeSelect).toBeInTheDocument()
+ expect(typeSelect).toHaveTextContent("Ollama")
+ })
+
+ it("submits the stored type unchanged when saving without edits", async () => {
+ const user = userEvent.setup()
+ mockUpdate.mockResolvedValue({
+ ...ollamaProvider,
+ })
+
+ render(
+ ,
+ { wrapper }
+ )
+
+ await user.click(screen.getByRole("button", { name: "Save source" }))
+
+ await waitFor(() => {
+ expect(mockUpdate).toHaveBeenCalled()
+ })
+ const requestBody = mockUpdate.mock.calls[0]?.[0]?.requestBody
+ expect(requestBody?.type).toBe("ollama")
+ // Saving unchanged submits the stored passthrough value verbatim.
+ expect(requestBody?.passthrough).toBe(false)
+ })
+
+ it("submits the stored ollama passthrough=true unchanged on save", async () => {
+ const user = userEvent.setup()
+ const passthroughOllama = { ...ollamaProvider, passthrough: true }
+ mockUpdate.mockResolvedValue({ ...passthroughOllama })
+
+ render(
+ ,
+ { wrapper }
+ )
+
+ await user.click(screen.getByRole("button", { name: "Save source" }))
+
+ await waitFor(() => {
+ expect(mockUpdate).toHaveBeenCalled()
+ })
+ const requestBody = mockUpdate.mock.calls[0]?.[0]?.requestBody
+ expect(requestBody?.type).toBe("ollama")
+ expect(requestBody?.passthrough).toBe(true)
+ })
+})
diff --git a/frontend/tests/custom-source-connection-item.test.tsx b/frontend/tests/custom-source-connection-item.test.tsx
new file mode 100644
index 0000000000..fe61cb1382
--- /dev/null
+++ b/frontend/tests/custom-source-connection-item.test.tsx
@@ -0,0 +1,129 @@
+import { render, screen } from "@testing-library/react"
+import userEvent from "@testing-library/user-event"
+import type { AgentCustomProviderRead } from "@/client"
+import {
+ type CustomSourceCard,
+ CustomSourceConnectionItem,
+} from "@/components/organization/org-settings-agent"
+
+jest.mock("@/components/icons", () => ({
+ ProviderIcon: ({ providerId }: { providerId: string }) => (
+
+ ),
+}))
+
+const provider: AgentCustomProviderRead = {
+ id: "prov-1",
+ organization_id: "org-1",
+ display_name: "Local gateway",
+ base_url: "https://gw.example.com/v1",
+ type: "generic_openai_compatible",
+ passthrough: false,
+ api_key_header: null,
+ last_refreshed_at: null,
+}
+
+function buildSource(
+ overrides: Partial = {}
+): CustomSourceCard {
+ return {
+ id: provider.id,
+ type: "openai_compatible_gateway",
+ flavor: null,
+ display_name: provider.display_name,
+ base_url: provider.base_url,
+ api_key_configured: false,
+ api_key_header: provider.api_key_header,
+ discovery_status: "loaded",
+ last_refreshed_at: null,
+ last_error: null,
+ provider,
+ models: [
+ {
+ id: "cat-1",
+ source_id: provider.id,
+ source_name: provider.display_name,
+ source_type: "openai_compatible_gateway",
+ model_provider: "custom-model-provider",
+ model_name: "gw-model-a",
+ metadata: {},
+ base_url: provider.base_url,
+ enabled: true,
+ },
+ {
+ id: "cat-2",
+ source_id: provider.id,
+ source_name: provider.display_name,
+ source_type: "openai_compatible_gateway",
+ model_provider: "custom-model-provider",
+ model_name: "gw-model-b",
+ metadata: {},
+ base_url: provider.base_url,
+ enabled: false,
+ },
+ ],
+ ...overrides,
+ }
+}
+
+function renderItem(source: CustomSourceCard) {
+ return render(
+
+ )
+}
+
+describe("CustomSourceConnectionItem", () => {
+ it("renders the enabled-count pill with the condensed model summary", () => {
+ renderItem(buildSource())
+ expect(screen.getByText("1 enabled")).toBeInTheDocument()
+ })
+
+ it("renders a no-models pill when the source has no discovered models", () => {
+ renderItem(buildSource({ models: [] }))
+ expect(screen.getByText("No models discovered")).toBeInTheDocument()
+ })
+
+ it("shows a Passthrough pill when passthrough is enabled", () => {
+ renderItem(buildSource({ provider: { ...provider, passthrough: true } }))
+ expect(screen.getByText("Passthrough")).toBeInTheDocument()
+ })
+
+ it("omits the Passthrough pill when passthrough is disabled", () => {
+ renderItem(buildSource())
+ expect(screen.queryByText("Passthrough")).not.toBeInTheDocument()
+ })
+
+ it("drops the provider slug, Custom URL chip, and source subtitle", () => {
+ renderItem(buildSource())
+ expect(screen.queryByText("custom-model-provider")).not.toBeInTheDocument()
+ expect(screen.queryByText("Custom URL")).not.toBeInTheDocument()
+ // The provider display name still appears in the header/menu, but not as a
+ // per-row subtitle; the model rows show only their own name.
+ expect(screen.getByText("gw-model-a")).toBeInTheDocument()
+ })
+
+ it("exposes the provider name and provider-level actions via the menu", async () => {
+ const user = userEvent.setup()
+ renderItem(buildSource())
+ expect(screen.getAllByText("Local gateway").length).toBeGreaterThan(0)
+
+ await user.click(screen.getByRole("button", { name: "Source actions" }))
+
+ expect(screen.getByRole("menuitem", { name: "Edit" })).toBeInTheDocument()
+ expect(
+ screen.getByRole("menuitem", { name: "Refresh" })
+ ).toBeInTheDocument()
+ expect(screen.getByRole("menuitem", { name: "Delete" })).toBeInTheDocument()
+ })
+})
diff --git a/tests/unit/test_agent_catalog_service.py b/tests/unit/test_agent_catalog_service.py
index 7b54c600fc..2d7a5b9a60 100644
--- a/tests/unit/test_agent_catalog_service.py
+++ b/tests/unit/test_agent_catalog_service.py
@@ -8,11 +8,12 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from tracecat.agent.access.service import AgentModelAccessService
from tracecat.agent.catalog.schemas import (
AzureOpenAICatalogUpdate,
BedrockCatalogUpdate,
)
-from tracecat.agent.catalog.service import AgentCatalogService
+from tracecat.agent.catalog.service import AgentCatalogService, DiscoveredModel
from tracecat.auth.types import Role
from tracecat.contexts import ctx_role
from tracecat.db.models import (
@@ -214,8 +215,8 @@ async def test_upsert_discovered_models_inserts_rows(
custom_provider_id=provider.id,
model_provider="custom-model-provider",
models=[
- {"id": "model-a", "context_window": 8192},
- {"id": "model-b", "context_window": 16384},
+ DiscoveredModel(model_name="model-a", metadata={"digest": "a"}),
+ DiscoveredModel(model_name="model-b", metadata={"digest": "b"}),
],
)
@@ -254,9 +255,9 @@ async def test_upsert_discovered_models_removes_stale_models(
custom_provider_id=provider.id,
model_provider="custom-model-provider",
models=[
- {"id": "model-a"},
- {"id": "model-b"},
- {"id": "model-c"},
+ DiscoveredModel(model_name="model-a"),
+ DiscoveredModel(model_name="model-b"),
+ DiscoveredModel(model_name="model-c"),
],
)
@@ -265,8 +266,8 @@ async def test_upsert_discovered_models_removes_stale_models(
custom_provider_id=provider.id,
model_provider="custom-model-provider",
models=[
- {"id": "model-b"},
- {"id": "model-d"},
+ DiscoveredModel(model_name="model-b"),
+ DiscoveredModel(model_name="model-d"),
],
)
@@ -304,7 +305,10 @@ async def test_upsert_discovered_models_clears_catalog_when_empty(
org_id=svc_organization.id,
custom_provider_id=provider.id,
model_provider="custom-model-provider",
- models=[{"id": "model-a"}, {"id": "model-b"}],
+ models=[
+ DiscoveredModel(model_name="model-a"),
+ DiscoveredModel(model_name="model-b"),
+ ],
)
count = await service.upsert_discovered_models(
@@ -1093,3 +1097,81 @@ async def test_is_catalog_id_enabled_respects_workspace_override(
)
is True
)
+
+
+@pytest.mark.anyio
+async def test_enable_model_is_idempotent_at_org_level(
+ session: AsyncSession,
+ svc_organization: Organization,
+) -> None:
+ """Enabling an already-enabled org model returns the existing row, no error."""
+ service = AgentModelAccessService(
+ session=session, role=_user_role(svc_organization.id)
+ )
+ row = AgentCatalog(
+ organization_id=svc_organization.id,
+ custom_provider_id=None,
+ model_provider="custom",
+ model_name="gw-model-a",
+ model_metadata={},
+ )
+ session.add(row)
+ await session.commit()
+ # Capture ids before the enable calls; the second enable rolls back a
+ # duplicate insert, expiring ORM instances still bound to this session.
+ org_id = svc_organization.id
+ catalog_id = row.id
+
+ first = await service.enable_model(catalog_id)
+ second = await service.enable_model(catalog_id)
+
+ # The duplicate call is a no-op that resolves to the same access row.
+ assert second.id == first.id
+
+ access_rows = (
+ (
+ await session.execute(
+ select(AgentModelAccess).where(
+ AgentModelAccess.organization_id == org_id,
+ AgentModelAccess.catalog_id == catalog_id,
+ AgentModelAccess.workspace_id.is_(None),
+ )
+ )
+ )
+ .scalars()
+ .all()
+ )
+ assert len(access_rows) == 1
+
+
+@pytest.mark.anyio
+async def test_enable_model_is_idempotent_at_workspace_level(
+ session: AsyncSession,
+ svc_organization: Organization,
+ svc_workspace,
+) -> None:
+ """Double-enabling a workspace-scoped model is a no-op success."""
+ service = AgentModelAccessService(
+ session=session, role=_user_role(svc_organization.id)
+ )
+ row = AgentCatalog(
+ organization_id=svc_organization.id,
+ custom_provider_id=None,
+ model_provider="custom",
+ model_name="gw-model-b",
+ model_metadata={},
+ )
+ session.add(row)
+ await session.commit()
+ catalog_id = row.id
+ workspace_id = svc_workspace.id
+
+ first = await service.enable_model(catalog_id, workspace_id=workspace_id)
+ second = await service.enable_model(catalog_id, workspace_id=workspace_id)
+
+ assert second.id == first.id
+ assert second.workspace_id == workspace_id
+
+ # The duplicate insert's rollback expired the shared workspace fixture;
+ # refresh it so fixture teardown sees a live instance.
+ await session.refresh(svc_workspace)
diff --git a/tests/unit/test_agent_gateway.py b/tests/unit/test_agent_gateway.py
index abde8bb6a7..b443bc3b70 100644
--- a/tests/unit/test_agent_gateway.py
+++ b/tests/unit/test_agent_gateway.py
@@ -13,8 +13,11 @@
from tracecat.agent.gateway import (
TracecatCallbackHandler,
_filter_allowed_model_settings,
+ _flatten_message_content,
_inject_provider_credentials,
_resolve_bedrock_runtime_credentials,
+ _sanitize_ollama_messages,
+ _sanitize_ollama_request,
user_api_key_auth,
)
from tracecat.agent.tokens import verify_llm_token
@@ -666,3 +669,322 @@ async def mock_get_provider_credentials(**_: object) -> dict[str, str]:
assert "context_management" not in result
assert "output_config" not in result
assert "output_format" not in result
+
+
+# ---------------------------------------------------------------------------
+# Ollama request sanitization
+# ---------------------------------------------------------------------------
+
+
+def test_flatten_message_content_joins_text_parts_and_drops_thinking() -> None:
+ content = [
+ {"type": "thinking", "thinking": "internal reasoning"},
+ {"type": "text", "text": "Hello "},
+ {"type": "redacted_thinking", "data": "xxxx"},
+ {"type": "text", "text": "there!"},
+ ]
+
+ assert _flatten_message_content(content) == "Hello there!"
+
+
+def test_flatten_message_content_passes_through_plain_string() -> None:
+ assert _flatten_message_content("already a string") == "already a string"
+
+
+def test_sanitize_ollama_messages_flattens_and_strips_reasoning_keys() -> None:
+ messages = [
+ {"role": "user", "content": "hi"},
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "thinking", "thinking": "the user greeted me"},
+ {"type": "text", "text": "Hello there!"},
+ ],
+ "thinking_blocks": [{"type": "thinking", "thinking": "hmm"}],
+ "reasoning_content": "hmm",
+ },
+ {"role": "user", "content": "say bye"},
+ ]
+
+ sanitized = _sanitize_ollama_messages(messages)
+
+ assert sanitized[0] == {"role": "user", "content": "hi"}
+ assert sanitized[1] == {"role": "assistant", "content": "Hello there!"}
+ assert sanitized[2] == {"role": "user", "content": "say bye"}
+ # Source structures are not mutated.
+ assert isinstance(messages[1]["content"], list)
+ assert "thinking_blocks" in messages[1]
+
+
+def test_sanitize_ollama_messages_preserves_tool_calls() -> None:
+ messages = [
+ {"role": "user", "content": "what is 2+2"},
+ {
+ "role": "assistant",
+ "content": [{"type": "text", "text": ""}],
+ "tool_calls": [
+ {
+ "id": "c1",
+ "type": "function",
+ "function": {"name": "calc", "arguments": "{}"},
+ }
+ ],
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "c1",
+ "content": [{"type": "text", "text": "4"}],
+ },
+ ]
+
+ sanitized = _sanitize_ollama_messages(messages)
+
+ assert sanitized[1]["tool_calls"] == messages[1]["tool_calls"]
+ assert sanitized[1]["content"] == ""
+ # tool message keeps its structure; list content is flattened.
+ assert sanitized[2]["role"] == "tool"
+ assert sanitized[2]["tool_call_id"] == "c1"
+ assert sanitized[2]["content"] == "4"
+
+
+def test_sanitize_ollama_request_drops_thinking_and_reasoning_effort() -> None:
+ data = {
+ "model": "qwen2.5",
+ "api_base": "http://host:11434/v1",
+ "thinking": {"type": "enabled", "budget_tokens": 2048},
+ "reasoning_effort": {"effort": "high", "summary": "detailed"},
+ "messages": [{"role": "user", "content": "hi"}],
+ }
+
+ _sanitize_ollama_request(data, {"CUSTOM_MODEL_PROVIDER_TYPE": "ollama"})
+
+ assert "thinking" not in data
+ assert "reasoning_effort" not in data
+ # Route stays on the catch-all against the /v1 base URL.
+ assert data["model"] == "qwen2.5"
+ assert data["api_base"] == "http://host:11434/v1"
+
+
+def test_sanitize_ollama_request_sanitizes_messages() -> None:
+ data = {
+ "model": "qwen2.5",
+ "messages": [
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "thinking", "thinking": "x"},
+ {"type": "text", "text": "Hi"},
+ ],
+ }
+ ],
+ }
+
+ _sanitize_ollama_request(data, {"CUSTOM_MODEL_PROVIDER_TYPE": "ollama"})
+
+ assert data["messages"][0]["content"] == "Hi"
+
+
+@pytest.mark.parametrize("provider_type", ["generic_openai_compatible", "litellm"])
+def test_sanitize_ollama_request_noop_for_non_ollama(provider_type: str) -> None:
+ data = {
+ "model": "gpt-4o",
+ "api_base": "https://gateway.example.com/v1",
+ "thinking": {"type": "enabled", "budget_tokens": 2048},
+ "messages": [
+ {
+ "role": "assistant",
+ "content": [{"type": "text", "text": "Hi"}],
+ }
+ ],
+ }
+
+ _sanitize_ollama_request(data, {"CUSTOM_MODEL_PROVIDER_TYPE": provider_type})
+
+ assert data["model"] == "gpt-4o"
+ assert data["api_base"] == "https://gateway.example.com/v1"
+ # Non-ollama routes are untouched: thinking and list content pass through.
+ assert data["thinking"] == {"type": "enabled", "budget_tokens": 2048}
+ assert data["messages"][0]["content"] == [{"type": "text", "text": "Hi"}]
+
+
+@pytest.mark.anyio
+async def test_pre_call_hook_ollama_drops_thinking_and_sanitizes(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def mock_get_provider_credentials(**_: object) -> dict[str, str]:
+ return {
+ "CUSTOM_MODEL_PROVIDER_TYPE": "ollama",
+ "CUSTOM_MODEL_PROVIDER_API_KEY": "ollama",
+ "CUSTOM_MODEL_PROVIDER_BASE_URL": "http://host:11434/v1",
+ "CUSTOM_MODEL_PROVIDER_MODEL_NAME": "qwen2.5",
+ }
+
+ monkeypatch.setattr(
+ "tracecat.agent.gateway.get_provider_credentials",
+ mock_get_provider_credentials,
+ )
+
+ user_api_key_dict = UserAPIKeyAuth(
+ api_key="llm-token",
+ metadata={
+ "workspace_id": "00000000-0000-0000-0000-000000000001",
+ "organization_id": "00000000-0000-0000-0000-000000000002",
+ "model": "qwen2.5",
+ "provider": "custom-model-provider",
+ "catalog_id": "00000000-0000-0000-0000-000000000003",
+ "base_url": "http://host:11434",
+ "model_settings": {"reasoning_effort": "high"},
+ },
+ )
+
+ handler = TracecatCallbackHandler()
+ result = await handler.async_pre_call_hook(
+ user_api_key_dict=user_api_key_dict,
+ cache=cast(DualCache, object()),
+ data={
+ "model": "qwen2.5",
+ "thinking": {"type": "enabled", "budget_tokens": 2048},
+ "messages": [
+ {"role": "user", "content": "hi"},
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "thinking", "thinking": "x"},
+ {"type": "text", "text": "Hello!"},
+ ],
+ },
+ ],
+ },
+ call_type="completion",
+ )
+
+ # Route stays on the catch-all; api_base carries /v1.
+ assert result["model"] == "qwen2.5"
+ assert result["api_base"] == "http://host:11434/v1"
+ assert result["api_base"].endswith("/v1")
+ assert "thinking" not in result
+ assert "reasoning_effort" not in result
+ assert result["messages"][1]["content"] == "Hello!"
+
+
+@pytest.mark.anyio
+async def test_pre_call_hook_generic_route_leaves_thinking(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Generic custom-provider routes are not sanitized (thinking passes)."""
+
+ async def mock_get_provider_credentials(**_: object) -> dict[str, str]:
+ return {
+ "CUSTOM_MODEL_PROVIDER_TYPE": "generic_openai_compatible",
+ "CUSTOM_MODEL_PROVIDER_API_KEY": "key",
+ "CUSTOM_MODEL_PROVIDER_BASE_URL": "https://gateway.example.com/v1",
+ "CUSTOM_MODEL_PROVIDER_MODEL_NAME": "gpt-4o",
+ }
+
+ monkeypatch.setattr(
+ "tracecat.agent.gateway.get_provider_credentials",
+ mock_get_provider_credentials,
+ )
+
+ user_api_key_dict = UserAPIKeyAuth(
+ api_key="llm-token",
+ metadata={
+ "workspace_id": "00000000-0000-0000-0000-000000000001",
+ "organization_id": "00000000-0000-0000-0000-000000000002",
+ "model": "gpt-4o",
+ "provider": "custom-model-provider",
+ "catalog_id": "00000000-0000-0000-0000-000000000003",
+ "base_url": "https://gateway.example.com/v1",
+ "model_settings": {},
+ },
+ )
+
+ handler = TracecatCallbackHandler()
+ result = await handler.async_pre_call_hook(
+ user_api_key_dict=user_api_key_dict,
+ cache=cast(DualCache, object()),
+ data={
+ "model": "gpt-4o",
+ "thinking": {"type": "enabled", "budget_tokens": 2048},
+ },
+ call_type="completion",
+ )
+
+ assert result["model"] == "gpt-4o"
+ assert result["api_base"] == "https://gateway.example.com/v1"
+ assert result["thinking"] == {"type": "enabled", "budget_tokens": 2048}
+
+
+@pytest.mark.anyio
+async def test_pre_call_hook_ollama_subagent_route_isolated(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """An ollama root and a non-ollama subagent each resolve their own route."""
+ creds_by_catalog = {
+ "00000000-0000-0000-0000-0000000000aa": {
+ "CUSTOM_MODEL_PROVIDER_TYPE": "ollama",
+ "CUSTOM_MODEL_PROVIDER_BASE_URL": "http://host:11434/v1",
+ "CUSTOM_MODEL_PROVIDER_MODEL_NAME": "qwen2.5",
+ },
+ "00000000-0000-0000-0000-0000000000bb": {
+ "CUSTOM_MODEL_PROVIDER_TYPE": "generic_openai_compatible",
+ "CUSTOM_MODEL_PROVIDER_BASE_URL": "https://gateway.example.com/v1",
+ "CUSTOM_MODEL_PROVIDER_MODEL_NAME": "gpt-4o",
+ },
+ }
+
+ async def mock_get_provider_credentials(**kwargs: Any) -> dict[str, str]:
+ return creds_by_catalog[str(kwargs["catalog_id"])]
+
+ monkeypatch.setattr(
+ "tracecat.agent.gateway.get_provider_credentials",
+ mock_get_provider_credentials,
+ )
+
+ metadata: dict[str, Any] = {
+ "workspace_id": "00000000-0000-0000-0000-000000000001",
+ "organization_id": "00000000-0000-0000-0000-000000000002",
+ "model": "qwen2.5",
+ "provider": "custom-model-provider",
+ "catalog_id": "00000000-0000-0000-0000-0000000000aa",
+ "model_settings": {},
+ "routes": {
+ "hosted_vllm/gpt-4o::tracecat-subagent::helper": {
+ "model": "gpt-4o",
+ "provider": "custom-model-provider",
+ "catalog_id": "00000000-0000-0000-0000-0000000000bb",
+ "base_url": None,
+ "model_settings": {},
+ "use_workspace_credentials": False,
+ }
+ },
+ }
+ handler = TracecatCallbackHandler()
+
+ # Root ollama route: thinking dropped, catch-all model + /v1 base URL.
+ root = await handler.async_pre_call_hook(
+ user_api_key_dict=UserAPIKeyAuth(api_key="llm-token", metadata=metadata),
+ cache=cast(DualCache, object()),
+ data={
+ "model": "qwen2.5",
+ "thinking": {"type": "enabled", "budget_tokens": 2048},
+ },
+ call_type="completion",
+ )
+ assert root["model"] == "qwen2.5"
+ assert root["api_base"] == "http://host:11434/v1"
+ assert "thinking" not in root
+
+ # Non-ollama subagent route stays on hosted_vllm passthrough with thinking.
+ sub = await handler.async_pre_call_hook(
+ user_api_key_dict=UserAPIKeyAuth(api_key="llm-token", metadata=metadata),
+ cache=cast(DualCache, object()),
+ data={
+ "model": "hosted_vllm/gpt-4o::tracecat-subagent::helper",
+ "thinking": {"type": "enabled", "budget_tokens": 2048},
+ },
+ call_type="completion",
+ )
+ assert sub["model"] == "gpt-4o"
+ assert sub["api_base"] == "https://gateway.example.com/v1"
+ assert sub["thinking"] == {"type": "enabled", "budget_tokens": 2048}
diff --git a/tests/unit/test_agent_management_service.py b/tests/unit/test_agent_management_service.py
index 159ad1cf97..1e49046f1a 100644
--- a/tests/unit/test_agent_management_service.py
+++ b/tests/unit/test_agent_management_service.py
@@ -17,6 +17,7 @@
from tracecat.agent.config import PROVIDER_CREDENTIAL_CONFIGS
from tracecat.agent.preset.activities import _load_custom_model_provider_creds
from tracecat.agent.preset.service import AgentPresetService
+from tracecat.agent.provider.types import CustomProviderType
from tracecat.agent.service import AgentManagementService
from tracecat.agent.types import AgentConfig
from tracecat.auth.types import Role
@@ -387,6 +388,7 @@ async def test_get_catalog_credentials_preserves_migrated_custom_provider_base_u
"CUSTOM_MODEL_PROVIDER_API_KEY": "sk-custom",
"CUSTOM_MODEL_PROVIDER_MODEL_NAME": "custom-model-provider",
"CUSTOM_MODEL_PROVIDER_PASSTHROUGH": "false",
+ "CUSTOM_MODEL_PROVIDER_TYPE": "generic_openai_compatible",
}
provider.base_url = "https://column.example.com/v1"
@@ -400,6 +402,222 @@ async def test_get_catalog_credentials_preserves_migrated_custom_provider_base_u
)
+@pytest.mark.anyio
+@pytest.mark.usefixtures("db")
+async def test_get_catalog_credentials_injects_ollama_placeholder_key(
+ session: AsyncSession,
+ svc_organization: Organization,
+ svc_workspace: Workspace,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Ollama needs no key; the OpenAI-compatible client requires one, so a
+ keyless ollama provider gets a placeholder ``CUSTOM_MODEL_PROVIDER_API_KEY``.
+ """
+ monkeypatch.setattr(
+ tracecat_config,
+ "TRACECAT__DB_ENCRYPTION_KEY",
+ Fernet.generate_key().decode(),
+ )
+ provider = AgentCustomProvider(
+ organization_id=svc_organization.id,
+ display_name="Ollama",
+ base_url="http://host:11434/v1",
+ type=CustomProviderType.OLLAMA.value,
+ passthrough=False,
+ encrypted_config=None,
+ )
+ session.add(provider)
+ await session.flush()
+ catalog = await _seed_catalog(
+ session,
+ org_id=svc_organization.id,
+ provider="custom-model-provider",
+ model_name="llama3",
+ custom_provider_id=provider.id,
+ )
+ await _grant_access(session, org_id=svc_organization.id, catalog_id=catalog.id)
+ await session.commit()
+
+ service = AgentManagementService(
+ session=session,
+ role=_db_role(svc_organization, svc_workspace),
+ )
+ credentials = await service.get_catalog_credentials(catalog.id)
+
+ assert credentials is not None
+ assert credentials["CUSTOM_MODEL_PROVIDER_API_KEY"] == "ollama"
+ assert credentials["CUSTOM_MODEL_PROVIDER_MODEL_NAME"] == "llama3"
+ # Type marker drives the gateway's thinking-drop + message sanitization.
+ assert credentials["CUSTOM_MODEL_PROVIDER_TYPE"] == "ollama"
+ # Bare server root gets /v1 ensured for the OpenAI-compatible runtime.
+ assert credentials["CUSTOM_MODEL_PROVIDER_BASE_URL"] == "http://host:11434/v1"
+
+
+@pytest.mark.anyio
+@pytest.mark.usefixtures("db")
+@pytest.mark.parametrize(
+ ("stored_base_url", "expected_base_url"),
+ [
+ ("http://host:11434", "http://host:11434/v1"),
+ ("http://host:11434/", "http://host:11434/v1"),
+ ("http://host:11434/v1", "http://host:11434/v1"),
+ ("http://host:11434/v1/", "http://host:11434/v1"),
+ ],
+)
+async def test_get_catalog_credentials_ensures_ollama_v1_base_url(
+ session: AsyncSession,
+ svc_organization: Organization,
+ svc_workspace: Workspace,
+ monkeypatch: pytest.MonkeyPatch,
+ stored_base_url: str,
+ expected_base_url: str,
+) -> None:
+ """Ollama runtime base_url ends with a single ``/v1`` (idempotent)."""
+ monkeypatch.setattr(
+ tracecat_config,
+ "TRACECAT__DB_ENCRYPTION_KEY",
+ Fernet.generate_key().decode(),
+ )
+ provider = AgentCustomProvider(
+ organization_id=svc_organization.id,
+ display_name="Ollama",
+ base_url=stored_base_url,
+ type=CustomProviderType.OLLAMA.value,
+ passthrough=False,
+ encrypted_config=None,
+ )
+ session.add(provider)
+ await session.flush()
+ catalog = await _seed_catalog(
+ session,
+ org_id=svc_organization.id,
+ provider="custom-model-provider",
+ model_name="llama3",
+ custom_provider_id=provider.id,
+ )
+ await _grant_access(session, org_id=svc_organization.id, catalog_id=catalog.id)
+ await session.commit()
+
+ service = AgentManagementService(
+ session=session,
+ role=_db_role(svc_organization, svc_workspace),
+ )
+ credentials = await service.get_catalog_credentials(catalog.id)
+
+ assert credentials is not None
+ assert credentials["CUSTOM_MODEL_PROVIDER_BASE_URL"] == expected_base_url
+ # The persisted column is never mutated by the runtime ensure.
+ await session.refresh(provider)
+ assert provider.base_url == stored_base_url
+
+
+@pytest.mark.anyio
+@pytest.mark.usefixtures("db")
+@pytest.mark.parametrize(
+ "provider_type",
+ [
+ CustomProviderType.GENERIC_OPENAI_COMPATIBLE,
+ CustomProviderType.LITELLM,
+ ],
+)
+async def test_get_catalog_credentials_leaves_non_ollama_base_url_untouched(
+ session: AsyncSession,
+ svc_organization: Organization,
+ svc_workspace: Workspace,
+ monkeypatch: pytest.MonkeyPatch,
+ provider_type: CustomProviderType,
+) -> None:
+ """Generic/LiteLLM base_url is returned as-is (no /v1 ensure)."""
+ monkeypatch.setattr(
+ tracecat_config,
+ "TRACECAT__DB_ENCRYPTION_KEY",
+ Fernet.generate_key().decode(),
+ )
+ provider = AgentCustomProvider(
+ organization_id=svc_organization.id,
+ display_name="Gateway",
+ base_url="https://gateway.example.com",
+ type=provider_type.value,
+ passthrough=False,
+ encrypted_config=None,
+ )
+ session.add(provider)
+ await session.flush()
+ catalog = await _seed_catalog(
+ session,
+ org_id=svc_organization.id,
+ provider="custom-model-provider",
+ model_name="gpt-4o",
+ custom_provider_id=provider.id,
+ )
+ await _grant_access(session, org_id=svc_organization.id, catalog_id=catalog.id)
+ await session.commit()
+
+ service = AgentManagementService(
+ session=session,
+ role=_db_role(svc_organization, svc_workspace),
+ )
+ credentials = await service.get_catalog_credentials(catalog.id)
+
+ assert credentials is not None
+ assert (
+ credentials["CUSTOM_MODEL_PROVIDER_BASE_URL"] == "https://gateway.example.com"
+ )
+ assert credentials["CUSTOM_MODEL_PROVIDER_TYPE"] == provider_type.value
+
+
+@pytest.mark.anyio
+@pytest.mark.usefixtures("db")
+async def test_get_catalog_credentials_keeps_ollama_stored_key(
+ session: AsyncSession,
+ svc_organization: Organization,
+ svc_workspace: Workspace,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A stored ollama key is not replaced by the placeholder."""
+ import orjson
+
+ from tracecat.auth.secrets import get_db_encryption_key
+ from tracecat.secrets.encryption import encrypt_value
+
+ monkeypatch.setattr(
+ tracecat_config,
+ "TRACECAT__DB_ENCRYPTION_KEY",
+ Fernet.generate_key().decode(),
+ )
+ blob = encrypt_value(
+ orjson.dumps({"api_key": "real-key"}), key=get_db_encryption_key()
+ )
+ provider = AgentCustomProvider(
+ organization_id=svc_organization.id,
+ display_name="Ollama",
+ base_url="http://host:11434/v1",
+ type=CustomProviderType.OLLAMA.value,
+ passthrough=False,
+ encrypted_config=blob,
+ )
+ session.add(provider)
+ await session.flush()
+ catalog = await _seed_catalog(
+ session,
+ org_id=svc_organization.id,
+ provider="custom-model-provider",
+ model_name="llama3",
+ custom_provider_id=provider.id,
+ )
+ await _grant_access(session, org_id=svc_organization.id, catalog_id=catalog.id)
+ await session.commit()
+
+ service = AgentManagementService(
+ session=session,
+ role=_db_role(svc_organization, svc_workspace),
+ )
+ credentials = await service.get_catalog_credentials(catalog.id)
+
+ assert credentials is not None
+ assert credentials["CUSTOM_MODEL_PROVIDER_API_KEY"] == "real-key"
+
+
@pytest.mark.anyio
@pytest.mark.usefixtures("db")
async def test_get_catalog_credentials_distinct_models_share_one_custom_provider(
@@ -620,6 +838,7 @@ async def test_load_custom_model_provider_creds_requires_catalog_access(
# The selected row's model_name is pinned (it's a real, non-placeholder
# name) so the provider blob can't override the per-row selection.
"CUSTOM_MODEL_PROVIDER_MODEL_NAME": "custom-model-provider",
+ "CUSTOM_MODEL_PROVIDER_TYPE": "generic_openai_compatible",
}
diff --git a/tests/unit/test_agent_preset_activities.py b/tests/unit/test_agent_preset_activities.py
index 06d7615b1c..9ebd16b15c 100644
--- a/tests/unit/test_agent_preset_activities.py
+++ b/tests/unit/test_agent_preset_activities.py
@@ -407,3 +407,42 @@ async def test_resolve_custom_model_provider_config_activity_returns_base_url(
assert result.base_url == "https://customer.example"
assert result.model_name == "provider/custom-model"
assert result.passthrough is True
+
+
+@pytest.mark.anyio
+async def test_resolve_custom_model_provider_config_activity_forwards_ollama_v1(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The v2 catalog path resolves creds via ``get_catalog_credentials``, which
+ ensures ``/v1`` for ollama; the activity forwards that base_url verbatim."""
+ catalog_id = uuid.uuid4()
+ service = SimpleNamespace()
+ role = Role(
+ type="service",
+ service_id="tracecat-api",
+ workspace_id=uuid.uuid4(),
+ organization_id=uuid.uuid4(),
+ )
+
+ monkeypatch.setattr(
+ "tracecat.agent.preset.activities.AgentManagementService.with_session",
+ lambda *_args, **_kwargs: _AsyncContext(service),
+ )
+ monkeypatch.setattr(
+ "tracecat.agent.preset.activities._load_custom_model_provider_creds",
+ AsyncMock(
+ return_value={
+ # Already-ensured shape returned by get_catalog_credentials.
+ "CUSTOM_MODEL_PROVIDER_BASE_URL": "http://host:11434/v1",
+ "CUSTOM_MODEL_PROVIDER_API_KEY": "ollama",
+ "CUSTOM_MODEL_PROVIDER_MODEL_NAME": "llama3",
+ }
+ ),
+ )
+
+ result = await resolve_custom_model_provider_config_activity(
+ role, catalog_id=catalog_id
+ )
+
+ assert result.base_url == "http://host:11434/v1"
+ assert result.model_name == "llama3"
diff --git a/tests/unit/test_agent_provider_service.py b/tests/unit/test_agent_provider_service.py
index dfa3771b31..1cf784afe0 100644
--- a/tests/unit/test_agent_provider_service.py
+++ b/tests/unit/test_agent_provider_service.py
@@ -13,12 +13,14 @@
from sqlalchemy.ext.asyncio import AsyncSession
from tracecat import config as tracecat_config
+from tracecat.agent.catalog.service import DiscoveredModel
from tracecat.agent.provider import service as provider_service_module
from tracecat.agent.provider.schemas import (
AgentCustomProviderCreate,
AgentCustomProviderUpdate,
)
from tracecat.agent.provider.service import AgentCustomProviderService
+from tracecat.agent.provider.types import CustomProviderType
from tracecat.auth.types import Role
from tracecat.db.models import (
AgentCatalog,
@@ -153,8 +155,11 @@ async def test_refresh_provider_catalog_upserts_models(
with patch.object(
service,
- "_discover_models",
- return_value=[{"id": "model-a"}, {"id": "model-b"}],
+ "_discover_openai_models",
+ return_value=[
+ DiscoveredModel(model_name="model-a", metadata={"id": "model-a"}),
+ DiscoveredModel(model_name="model-b", metadata={"id": "model-b"}),
+ ],
):
await service.refresh_provider_catalog(provider.id)
@@ -204,8 +209,12 @@ async def test_refresh_provider_catalog_uses_migrated_encrypted_base_url_fallbac
with patch.object(
service,
- "_discover_models",
- return_value=[{"id": "migrated-model"}],
+ "_discover_openai_models",
+ return_value=[
+ DiscoveredModel(
+ model_name="migrated-model", metadata={"id": "migrated-model"}
+ )
+ ],
) as discover:
await service.refresh_provider_catalog(provider.id)
@@ -283,6 +292,82 @@ async def get(self, url: str, headers: dict[str, str]):
assert result is True
+def _recording_client(captured: list[str]) -> type:
+ """Build an httpx.AsyncClient stand-in that records the requested URL."""
+
+ class _Response:
+ status_code = 200
+
+ class _Client:
+ def __init__(self, *args: object, **kwargs: object) -> None:
+ pass
+
+ async def __aenter__(self) -> _Client:
+ return self
+
+ async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
+ return False
+
+ async def get(self, url: str, headers: dict[str, str]) -> _Response:
+ captured.append(url)
+ return _Response()
+
+ return _Client
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize(
+ "base_url",
+ ["http://localhost:11434", "http://localhost:11434/v1"],
+)
+async def test_validate_provider_ollama_probes_api_tags(
+ session: AsyncSession,
+ svc_organization: Organization,
+ base_url: str,
+) -> None:
+ """Ollama validation hits ``/api/tags`` off the server root for bare and
+ ``/v1``-suffixed URLs alike (strip is idempotent)."""
+ service = AgentCustomProviderService(session=session, role=_role(svc_organization))
+ captured: list[str] = []
+
+ with patch.object(
+ provider_service_module.httpx, "AsyncClient", _recording_client(captured)
+ ):
+ result = await service.validate_provider(
+ base_url=base_url,
+ provider_type=CustomProviderType.OLLAMA,
+ )
+
+ assert result is True
+ assert captured == ["http://localhost:11434/api/tags"]
+
+
+@pytest.mark.anyio
+@pytest.mark.parametrize(
+ "provider_type",
+ [CustomProviderType.GENERIC_OPENAI_COMPATIBLE, CustomProviderType.LITELLM],
+)
+async def test_validate_provider_openai_types_probe_models(
+ session: AsyncSession,
+ svc_organization: Organization,
+ provider_type: CustomProviderType,
+) -> None:
+ """Generic and LiteLLM validation still probe ``{base_url}/models``."""
+ service = AgentCustomProviderService(session=session, role=_role(svc_organization))
+ captured: list[str] = []
+
+ with patch.object(
+ provider_service_module.httpx, "AsyncClient", _recording_client(captured)
+ ):
+ result = await service.validate_provider(
+ base_url="https://gateway.example.com",
+ provider_type=provider_type,
+ )
+
+ assert result is True
+ assert captured == ["https://gateway.example.com/models"]
+
+
async def _load_raw_provider(
session: AsyncSession, provider_id: uuid.UUID
) -> AgentCustomProvider:
@@ -374,3 +459,277 @@ async def test_update_provider_clears_all_secrets_sets_encrypted_config_null(
raw = await _load_raw_provider(session, created.id)
assert raw.encrypted_config is None
+
+
+class _JSONResponse:
+ """Minimal httpx.Response stand-in for discovery tests."""
+
+ def __init__(self, payload: object) -> None:
+ self._payload = payload
+
+ def raise_for_status(self) -> None:
+ return None
+
+ def json(self) -> object:
+ return self._payload
+
+
+class _RecordingClient:
+ """httpx.AsyncClient stub that records GET URLs and serves a payload map."""
+
+ def __init__(self, payloads: dict[str, object]) -> None:
+ self._payloads = payloads
+ self.requested_urls: list[str] = []
+
+ async def __aenter__(self) -> _RecordingClient:
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb) -> bool:
+ return False
+
+ async def get(self, url: str, headers: dict[str, str]) -> _JSONResponse:
+ self.requested_urls.append(url)
+ return _JSONResponse(self._payloads[url])
+
+
+def _patch_httpx(client: _RecordingClient):
+ return patch.object(
+ provider_service_module.httpx, "AsyncClient", return_value=client
+ )
+
+
+async def _catalog_row(
+ session: AsyncSession, provider_id: uuid.UUID, model_name: str
+) -> AgentCatalog:
+ return (
+ await session.execute(
+ select(AgentCatalog).where(
+ AgentCatalog.custom_provider_id == provider_id,
+ AgentCatalog.model_name == model_name,
+ )
+ )
+ ).scalar_one()
+
+
+@pytest.mark.anyio
+async def test_generic_refresh_only_calls_models_endpoint(
+ session: AsyncSession,
+ svc_organization: Organization,
+) -> None:
+ service = AgentCustomProviderService(session=session, role=_role(svc_organization))
+ provider = await service.create_provider(
+ AgentCustomProviderCreate(
+ display_name="Generic",
+ base_url="https://api.example.com",
+ type=CustomProviderType.GENERIC_OPENAI_COMPATIBLE,
+ )
+ )
+
+ client = _RecordingClient(
+ {"https://api.example.com/models": {"data": [{"id": "gpt-x"}]}}
+ )
+ with _patch_httpx(client):
+ await service.refresh_provider_catalog(provider.id)
+
+ assert client.requested_urls == ["https://api.example.com/models"]
+ forbidden = ("/api/tags", "/api/show", "/v1/model/info", "/model_group/info")
+ assert not any(any(f in url for f in forbidden) for url in client.requested_urls)
+
+
+@pytest.mark.anyio
+async def test_ollama_refresh_calls_tags_and_stores_digest(
+ session: AsyncSession,
+ svc_organization: Organization,
+) -> None:
+ service = AgentCustomProviderService(session=session, role=_role(svc_organization))
+ # base_url includes a trailing /v1 that must be stripped for the gateway root.
+ provider = await service.create_provider(
+ AgentCustomProviderCreate(
+ display_name="Ollama",
+ base_url="http://host:11434/v1",
+ type=CustomProviderType.OLLAMA,
+ )
+ )
+
+ client = _RecordingClient(
+ {
+ "http://host:11434/api/tags": {
+ "models": [{"name": "llama3", "digest": "sha256:aaa"}]
+ }
+ }
+ )
+ with _patch_httpx(client):
+ await service.refresh_provider_catalog(provider.id)
+
+ assert client.requested_urls == ["http://host:11434/api/tags"]
+ row = await _catalog_row(session, provider.id, "llama3")
+ assert (row.model_metadata or {}).get("digest") == "sha256:aaa"
+
+
+@pytest.mark.anyio
+async def test_ollama_refresh_no_v1_suffix_unchanged(
+ session: AsyncSession,
+ svc_organization: Organization,
+) -> None:
+ service = AgentCustomProviderService(session=session, role=_role(svc_organization))
+ provider = await service.create_provider(
+ AgentCustomProviderCreate(
+ display_name="OllamaBare",
+ base_url="http://host:11434",
+ type=CustomProviderType.OLLAMA,
+ )
+ )
+
+ client = _RecordingClient(
+ {"http://host:11434/api/tags": {"models": [{"name": "llama3", "digest": "d1"}]}}
+ )
+ with _patch_httpx(client):
+ await service.refresh_provider_catalog(provider.id)
+
+ assert client.requested_urls == ["http://host:11434/api/tags"]
+
+
+@pytest.mark.anyio
+async def test_create_ollama_passthrough_true_succeeds(
+ session: AsyncSession,
+ svc_organization: Organization,
+) -> None:
+ service = AgentCustomProviderService(session=session, role=_role(svc_organization))
+ created = await service.create_provider(
+ AgentCustomProviderCreate(
+ display_name="Ollama",
+ type=CustomProviderType.OLLAMA,
+ passthrough=True,
+ )
+ )
+ assert created.type is CustomProviderType.OLLAMA
+ assert created.passthrough is True
+
+
+@pytest.mark.anyio
+async def test_update_ollama_passthrough_true_succeeds(
+ session: AsyncSession,
+ svc_organization: Organization,
+) -> None:
+ service = AgentCustomProviderService(session=session, role=_role(svc_organization))
+ created = await service.create_provider(
+ AgentCustomProviderCreate(display_name="Prov")
+ )
+
+ updated = await service.update_provider(
+ created.id,
+ AgentCustomProviderUpdate(type=CustomProviderType.OLLAMA, passthrough=True),
+ )
+
+ assert updated.type is CustomProviderType.OLLAMA
+ assert updated.passthrough is True
+
+
+@pytest.mark.anyio
+async def test_create_litellm_passthrough_false_accepted() -> None:
+ provider = AgentCustomProviderCreate(
+ display_name="LL",
+ type=CustomProviderType.LITELLM,
+ passthrough=False,
+ )
+ assert provider.passthrough is False
+
+
+@pytest.mark.anyio
+async def test_create_litellm_default_passthrough_false() -> None:
+ # The schema default stays False for all types; the wizard supplies the
+ # litellm-on prefill.
+ provider = AgentCustomProviderCreate(
+ display_name="LL", type=CustomProviderType.LITELLM
+ )
+ assert provider.passthrough is False
+
+
+@pytest.mark.anyio
+async def test_update_litellm_passthrough_false_succeeds(
+ session: AsyncSession,
+ svc_organization: Organization,
+) -> None:
+ service = AgentCustomProviderService(session=session, role=_role(svc_organization))
+ created = await service.create_provider(
+ AgentCustomProviderCreate(display_name="Prov")
+ )
+
+ updated = await service.update_provider(
+ created.id,
+ AgentCustomProviderUpdate(type=CustomProviderType.LITELLM, passthrough=False),
+ )
+
+ assert updated.type is CustomProviderType.LITELLM
+ assert updated.passthrough is False
+
+
+@pytest.mark.anyio
+async def test_update_flip_to_ollama_preserves_stored_passthrough(
+ session: AsyncSession,
+ svc_organization: Organization,
+) -> None:
+ service = AgentCustomProviderService(session=session, role=_role(svc_organization))
+ created = await service.create_provider(
+ AgentCustomProviderCreate(display_name="Prov", passthrough=True)
+ )
+
+ updated = await service.update_provider(
+ created.id,
+ AgentCustomProviderUpdate(type=CustomProviderType.OLLAMA),
+ )
+
+ assert updated.type is CustomProviderType.OLLAMA
+ # Type flips do not mutate stored passthrough.
+ assert updated.passthrough is True
+
+
+@pytest.mark.anyio
+async def test_update_flip_to_litellm_preserves_stored_passthrough(
+ session: AsyncSession,
+ svc_organization: Organization,
+) -> None:
+ service = AgentCustomProviderService(session=session, role=_role(svc_organization))
+ created = await service.create_provider(
+ AgentCustomProviderCreate(display_name="Prov", passthrough=False)
+ )
+
+ updated = await service.update_provider(
+ created.id,
+ AgentCustomProviderUpdate(type=CustomProviderType.LITELLM),
+ )
+
+ assert updated.type is CustomProviderType.LITELLM
+ # Type flips do not mutate stored passthrough.
+ assert updated.passthrough is False
+
+
+@pytest.mark.anyio
+async def test_update_flip_litellm_to_generic_preserves_stored_passthrough(
+ session: AsyncSession,
+ svc_organization: Organization,
+) -> None:
+ service = AgentCustomProviderService(session=session, role=_role(svc_organization))
+ created = await service.create_provider(
+ AgentCustomProviderCreate(
+ display_name="Prov",
+ type=CustomProviderType.LITELLM,
+ passthrough=True,
+ )
+ )
+ assert created.passthrough is True
+
+ updated = await service.update_provider(
+ created.id,
+ AgentCustomProviderUpdate(type=CustomProviderType.GENERIC_OPENAI_COMPATIBLE),
+ )
+
+ assert updated.type is CustomProviderType.GENERIC_OPENAI_COMPATIBLE
+ # No type-driven mutation of stored passthrough.
+ assert updated.passthrough is True
+
+
+@pytest.mark.anyio
+async def test_type_defaults_to_generic() -> None:
+ provider = AgentCustomProviderCreate(display_name="Default")
+ assert provider.type is CustomProviderType.GENERIC_OPENAI_COMPATIBLE
diff --git a/tracecat/agent/access/service.py b/tracecat/agent/access/service.py
index e798b0b18b..620d4f5b15 100644
--- a/tracecat/agent/access/service.py
+++ b/tracecat/agent/access/service.py
@@ -12,7 +12,7 @@
from tracecat.audit.logger import audit_log
from tracecat.authz.controls import require_scope
from tracecat.db.models import AgentCatalog, AgentModelAccess, Workspace
-from tracecat.exceptions import TracecatNotFoundError, TracecatValidationError
+from tracecat.exceptions import TracecatNotFoundError
from tracecat.pagination import BaseCursorPaginator, CursorPaginationParams
from tracecat.service import BaseOrgService
@@ -76,9 +76,9 @@ async def enable_model(
await self.session.rollback()
pgcode = getattr(getattr(err, "orig", None), "pgcode", None)
if pgcode == "23505":
- raise TracecatValidationError(
- f"Model access for catalog {catalog_id} already enabled"
- ) from err
+ # Already enabled — treat as an idempotent no-op and return the
+ # existing access row rather than erroring.
+ return await self._get_access_row(catalog_id, workspace_id)
if pgcode == "23503":
raise TracecatNotFoundError(
f"Catalog {catalog_id} or workspace {workspace_id} not found"
@@ -87,6 +87,29 @@ async def enable_model(
await self.session.refresh(access)
return AgentModelAccessRead.model_validate(access)
+ async def _get_access_row(
+ self,
+ catalog_id: UUID,
+ workspace_id: UUID | None,
+ ) -> AgentModelAccessRead:
+ """Fetch the existing org/workspace access row for a catalog entry."""
+ workspace_condition = (
+ AgentModelAccess.workspace_id == workspace_id
+ if workspace_id is not None
+ else AgentModelAccess.workspace_id.is_(None)
+ )
+ stmt = select(AgentModelAccess).where(
+ AgentModelAccess.organization_id == self.organization_id,
+ workspace_condition,
+ AgentModelAccess.catalog_id == catalog_id,
+ )
+ existing = (await self.session.execute(stmt)).scalar_one_or_none()
+ if existing is None:
+ raise TracecatNotFoundError(
+ f"Model access for catalog {catalog_id} not found"
+ )
+ return AgentModelAccessRead.model_validate(existing)
+
@require_scope("agent:delete")
@audit_log(
resource_type="agent_model_access",
diff --git a/tracecat/agent/catalog/router.py b/tracecat/agent/catalog/router.py
index 0301a5febc..e390ab7503 100644
--- a/tracecat/agent/catalog/router.py
+++ b/tracecat/agent/catalog/router.py
@@ -106,11 +106,8 @@ async def create_catalog_entry(
status_code=status.HTTP_409_CONFLICT,
detail=str(e),
) from e
- try:
- await access_service.enable_model(row.id)
- except TracecatValidationError:
- # Already enabled — ignore duplicate access row
- pass
+ # enable_model is idempotent: enabling an already-enabled row is a no-op.
+ await access_service.enable_model(row.id)
return AgentCatalogRead.model_validate(row)
diff --git a/tracecat/agent/catalog/service.py b/tracecat/agent/catalog/service.py
index 4e7e3a0c45..16431d1c3c 100644
--- a/tracecat/agent/catalog/service.py
+++ b/tracecat/agent/catalog/service.py
@@ -1,6 +1,6 @@
"""Service for managing agent model catalog."""
-from collections.abc import Mapping, Sequence
+from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any, TypedDict
@@ -44,6 +44,17 @@ class PlatformCatalogEntry:
metadata: dict[str, Any] = field(default_factory=dict)
+@dataclass(frozen=True, slots=True)
+class DiscoveredModel:
+ """A model returned by a custom provider's discovery endpoint.
+
+ ``metadata`` is merged onto the existing row's ``model_metadata``.
+ """
+
+ model_name: str
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
class AgentCatalogService(BaseService):
"""Manage model catalog entries."""
@@ -467,23 +478,45 @@ async def upsert_discovered_models(
*,
org_id: UUID,
custom_provider_id: UUID,
- models: Sequence[Mapping[str, Any]],
+ models: Sequence[DiscoveredModel],
model_provider: str,
) -> int:
- """Bulk upsert discovered models for a custom provider."""
- values: list[_CatalogRowValues] = []
+ """Bulk upsert discovered models for a custom provider.
+
+ Merges ``model_metadata`` rather than clobbering it: existing keys
+ (notably an ollama ``digest``) are preserved unless a model explicitly
+ overwrites the key.
+ """
now = datetime.now(UTC)
- for raw in models:
- model_name = raw.get("id") or raw.get("model_name")
- if not isinstance(model_name, str):
- continue
+ model_names = [m.model_name for m in models]
+
+ existing_metadata: dict[str, dict[str, Any]] = {}
+ if model_names:
+ existing_rows = (
+ await self.session.execute(
+ select(AgentCatalog.model_name, AgentCatalog.model_metadata).where(
+ AgentCatalog.organization_id == org_id,
+ AgentCatalog.custom_provider_id == custom_provider_id,
+ AgentCatalog.model_provider == model_provider,
+ AgentCatalog.model_name.in_(model_names),
+ )
+ )
+ ).all()
+ existing_metadata = {
+ name: dict(metadata or {}) for name, metadata in existing_rows
+ }
+
+ values: list[_CatalogRowValues] = []
+ for model in models:
+ merged = existing_metadata.get(model.model_name, {})
+ merged = {**merged, **model.metadata}
values.append(
{
"organization_id": org_id,
"custom_provider_id": custom_provider_id,
"model_provider": model_provider,
- "model_name": model_name,
- "model_metadata": dict(raw),
+ "model_name": model.model_name,
+ "model_metadata": merged,
"last_refreshed_at": now,
}
)
diff --git a/tracecat/agent/gateway.py b/tracecat/agent/gateway.py
index bfcf18b010..fda8bef78c 100644
--- a/tracecat/agent/gateway.py
+++ b/tracecat/agent/gateway.py
@@ -18,6 +18,7 @@
from tracecat import config as app_config
from tracecat.agent.litellm_compat import apply_patch
+from tracecat.agent.provider.types import CustomProviderType, ensure_ollama_v1
from tracecat.agent.service import AgentManagementService
from tracecat.agent.tokens import verify_llm_token
from tracecat.auth.types import Role
@@ -445,6 +446,10 @@ async def async_pre_call_hook(
if provider == "bedrock":
_strip_bedrock_unsupported_params(data)
+ # Sanitize last so model_settings/base_url overrides are already applied.
+ if provider == "custom-model-provider":
+ _sanitize_ollama_request(data, creds)
+
logger.info(
"Injected credentials for LiteLLM call",
workspace_id=str(workspace_id),
@@ -537,6 +542,71 @@ def _strip_bedrock_unsupported_params(data: dict) -> None:
data.pop("reasoning_effort", None)
+# Message keys carrying reasoning artifacts that Ollama's /v1 parser chokes on.
+_OLLAMA_DROP_MESSAGE_KEYS = ("thinking_blocks", "reasoning_content")
+
+
+def _flatten_message_content(content: Any) -> Any:
+ """Flatten list content parts to a plain string of concatenated text parts.
+
+ Ollama's /v1 parser rejects assistant/user content lists that carry
+ thinking/redacted_thinking parts; keep only ``type == "text"`` parts.
+ """
+ if not isinstance(content, list):
+ return content
+ texts = [
+ text
+ for part in content
+ if isinstance(part, dict) and part.get("type") == "text"
+ if isinstance((text := part.get("text")), str)
+ ]
+ return "".join(texts)
+
+
+def _sanitize_ollama_messages(messages: Any) -> list[Any]:
+ """Rebuild messages so Ollama's strict /v1 parser accepts replayed turns.
+
+ Non-destructive: builds new message dicts, dropping reasoning-artifact keys
+ and flattening list content. ``tool_calls`` and ``role: tool`` messages keep
+ their structure; only their list content is flattened.
+ """
+ sanitized: list[Any] = []
+ for message in messages:
+ if not isinstance(message, dict):
+ sanitized.append(message)
+ continue
+ new_message = {
+ key: value
+ for key, value in message.items()
+ if key not in _OLLAMA_DROP_MESSAGE_KEYS
+ }
+ if "content" in new_message:
+ new_message["content"] = _flatten_message_content(new_message["content"])
+ sanitized.append(new_message)
+ return sanitized
+
+
+def _sanitize_ollama_request(data: dict, creds: dict[str, str]) -> None:
+ """Adapt an ollama-type custom-provider request for Ollama's strict /v1.
+
+ Ollama routes go back through the ``hosted_vllm/*`` catch-all against the
+ ``/v1``-ensured base URL (native ``ollama_chat`` mangles streamed reasoning).
+ Ollama rejects any reasoning param on models without the thinking capability,
+ so drop ``thinking``/``reasoning_effort`` rather than translate, and sanitize
+ replayed messages so structured assistant turns don't 400.
+ """
+ if creds.get("CUSTOM_MODEL_PROVIDER_TYPE") != CustomProviderType.OLLAMA.value:
+ return
+ data.pop("thinking", None)
+ data.pop("reasoning_effort", None)
+ # Ollama's OpenAI-compatible surface lives only under /v1; a base_url
+ # override earlier in the hook may have reset it to the bare server root.
+ if base_url := data.get("api_base"):
+ data["api_base"] = ensure_ollama_v1(base_url)
+ if isinstance((messages := data.get("messages")), list):
+ data["messages"] = _sanitize_ollama_messages(messages)
+
+
def _inject_provider_credentials(
data: dict,
provider: str,
diff --git a/tracecat/agent/provider/router.py b/tracecat/agent/provider/router.py
index c4a07bb344..97936f6fd0 100644
--- a/tracecat/agent/provider/router.py
+++ b/tracecat/agent/provider/router.py
@@ -103,6 +103,11 @@ async def update_custom_provider(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
) from e
+ except ValueError as e:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=str(e),
+ ) from e
@router.delete(
@@ -166,6 +171,7 @@ async def validate_custom_provider_connection(
service = AgentCustomProviderService(session=session, role=role)
is_valid = await service.validate_provider(
base_url=provider.base_url or "",
+ provider_type=provider.type,
api_key=provider.api_key,
api_key_header=provider.api_key_header,
custom_headers=provider.custom_headers,
diff --git a/tracecat/agent/provider/schemas.py b/tracecat/agent/provider/schemas.py
index 95d53f3484..85e6d7fc43 100644
--- a/tracecat/agent/provider/schemas.py
+++ b/tracecat/agent/provider/schemas.py
@@ -6,6 +6,8 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator
+from tracecat.agent.provider.types import CustomProviderType
+
def validate_base_url(value: str | None) -> str | None:
"""Validate a base_url is http(s) and has a hostname."""
@@ -24,6 +26,9 @@ class AgentCustomProviderCreate(BaseModel):
display_name: str = Field(..., max_length=200)
base_url: str | None = Field(default=None, max_length=500)
+ type: CustomProviderType = Field(
+ default=CustomProviderType.GENERIC_OPENAI_COMPATIBLE
+ )
passthrough: bool = Field(default=False)
api_key_header: str | None = Field(default=None, max_length=120)
api_key: str | None = Field(default=None)
@@ -44,6 +49,7 @@ class AgentCustomProviderRead(BaseModel):
organization_id: UUID
display_name: str
base_url: str | None
+ type: CustomProviderType
passthrough: bool
api_key_header: str | None
last_refreshed_at: datetime | None
@@ -54,6 +60,7 @@ class AgentCustomProviderUpdate(BaseModel):
display_name: str | None = Field(default=None, max_length=200)
base_url: str | None = Field(default=None, max_length=500)
+ type: CustomProviderType | None = None
passthrough: bool | None = None
api_key_header: str | None = Field(default=None, max_length=120)
api_key: str | None = None
diff --git a/tracecat/agent/provider/service.py b/tracecat/agent/provider/service.py
index 56b0561e26..cd7649f796 100644
--- a/tracecat/agent/provider/service.py
+++ b/tracecat/agent/provider/service.py
@@ -13,13 +13,17 @@
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
-from tracecat.agent.catalog.service import AgentCatalogService
+from tracecat.agent.catalog.service import AgentCatalogService, DiscoveredModel
from tracecat.agent.provider.schemas import (
AgentCustomProviderCreate,
AgentCustomProviderRead,
AgentCustomProviderUpdate,
)
-from tracecat.agent.provider.types import ResolvedCustomProviderCredentials
+from tracecat.agent.provider.types import (
+ CustomProviderType,
+ ResolvedCustomProviderCredentials,
+ ollama_gateway_root,
+)
from tracecat.audit.logger import audit_log
from tracecat.auth.secrets import get_db_encryption_key
from tracecat.authz.controls import require_scope
@@ -88,6 +92,7 @@ async def create_provider(
organization_id=self.organization_id,
display_name=provider.display_name,
base_url=provider.base_url,
+ type=provider.type.value,
passthrough=provider.passthrough,
api_key_header=provider.api_key_header,
encrypted_config=encrypted_config,
@@ -284,7 +289,10 @@ async def update_provider(
model.encrypted_config = None
for key, value in update_data.items():
+ if key == "type" and isinstance(value, CustomProviderType):
+ value = value.value
setattr(model, key, value)
+
await self.session.commit()
await self.session.refresh(model)
return AgentCustomProviderRead.model_validate(model)
@@ -342,14 +350,24 @@ async def refresh_provider_catalog(self, provider_id: UUID) -> None:
api_key = provider_config.get("api_key")
custom_headers = provider_config.get("custom_headers")
- models = await self._discover_models(
- base_url,
- api_key=api_key if isinstance(api_key, str) else None,
- custom_headers=(
- custom_headers if isinstance(custom_headers, dict) else None
- ),
- api_key_header=provider.api_key_header,
- )
+ provider_type = CustomProviderType(provider.type)
+ resolved_api_key = api_key if isinstance(api_key, str) else None
+ resolved_headers = custom_headers if isinstance(custom_headers, dict) else None
+
+ if provider_type is CustomProviderType.OLLAMA:
+ models = await self._discover_ollama_models(
+ base_url,
+ api_key=resolved_api_key,
+ custom_headers=resolved_headers,
+ api_key_header=provider.api_key_header,
+ )
+ else:
+ models = await self._discover_openai_models(
+ base_url,
+ api_key=resolved_api_key,
+ custom_headers=resolved_headers,
+ api_key_header=provider.api_key_header,
+ )
catalog_service = AgentCatalogService(session=self.session)
await catalog_service.upsert_discovered_models(
@@ -362,16 +380,18 @@ async def refresh_provider_catalog(self, provider_id: UUID) -> None:
provider.last_refreshed_at = datetime.now(UTC)
await self.session.commit()
+ catalog_ids = await self._provider_catalog_ids(provider_id)
+
# Auto-grant org-wide access to every catalog row this custom provider
# now exposes. Orgs without the ``agent_addons`` entitlement cannot
# toggle per-model enablement, so discovering a model has to double as
# enabling it; idempotent via the unique index on (org, workspace,
# catalog).
- await self._auto_grant_custom_provider_access(provider_id)
+ await self._auto_grant_custom_provider_access(catalog_ids)
- async def _auto_grant_custom_provider_access(self, provider_id: UUID) -> None:
- """Grant org-wide access to all catalog rows for a custom provider."""
- catalog_ids = (
+ async def _provider_catalog_ids(self, provider_id: UUID) -> list[UUID]:
+ """Return all catalog row ids owned by this provider in the org."""
+ return list(
(
await self.session.execute(
select(AgentCatalog.id).where(
@@ -383,6 +403,9 @@ async def _auto_grant_custom_provider_access(self, provider_id: UUID) -> None:
.scalars()
.all()
)
+
+ async def _auto_grant_custom_provider_access(self, catalog_ids: list[UUID]) -> None:
+ """Grant org-wide access to the given custom-provider catalog rows."""
if not catalog_ids:
return
@@ -408,72 +431,132 @@ async def _auto_grant_custom_provider_access(self, provider_id: UUID) -> None:
await self.session.execute(stmt)
await self.session.commit()
+ async def validate_provider(
+ self,
+ base_url: str,
+ provider_type: CustomProviderType = CustomProviderType.GENERIC_OPENAI_COMPATIBLE,
+ api_key: str | None = None,
+ api_key_header: str | None = None,
+ custom_headers: dict[str, str] | None = None,
+ ) -> bool:
+ """Test provider connectivity, probing the type-appropriate endpoint.
+
+ Ollama serves discovery under the native ``/api/tags`` off the server
+ root; other types probe OpenAI-compatible ``{base_url}/models``.
+ """
+ if not base_url or not base_url.strip():
+ return False
+ headers = self._build_discovery_headers(
+ api_key=api_key,
+ api_key_header=api_key_header,
+ custom_headers=custom_headers,
+ )
+ if provider_type is CustomProviderType.OLLAMA:
+ url = f"{self._ollama_gateway_root(base_url)}/api/tags"
+ else:
+ url = f"{base_url.rstrip('/')}/models"
+ try:
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.get(url, headers=headers)
+ return response.status_code == 200
+ except Exception:
+ return False
+
@staticmethod
- async def _fetch_models(
+ def _build_discovery_headers(
*,
- base_url: str,
api_key: str | None,
api_key_header: str | None,
custom_headers: dict[str, str] | None,
- timeout: float,
- ) -> httpx.Response:
- """Make a GET /models request against a provider base URL."""
+ ) -> dict[str, str]:
+ """Build request headers for a discovery call."""
headers = custom_headers.copy() if custom_headers else {}
if api_key:
if not api_key_header:
headers["Authorization"] = f"Bearer {api_key}"
else:
headers[api_key_header] = api_key
- async with httpx.AsyncClient(timeout=timeout) as client:
- return await client.get(
- f"{base_url.rstrip('/')}/models",
- headers=headers,
- )
+ return headers
- async def validate_provider(
+ @staticmethod
+ async def _get_json(url: str, headers: dict[str, str]) -> Any:
+ """GET a discovery URL and return parsed JSON."""
+ try:
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ response = await client.get(url, headers=headers)
+ response.raise_for_status()
+ return response.json()
+ except httpx.HTTPError as err:
+ raise ValueError(f"Failed to discover models: {err}") from err
+
+ async def _discover_openai_models(
self,
base_url: str,
api_key: str | None = None,
- api_key_header: str | None = None,
custom_headers: dict[str, str] | None = None,
- ) -> bool:
- """Test provider connectivity."""
- if not base_url or not base_url.strip():
- return False
- try:
- response = await self._fetch_models(
- base_url=base_url,
- api_key=api_key,
- api_key_header=api_key_header,
- custom_headers=custom_headers,
- timeout=10.0,
+ api_key_header: str | None = None,
+ ) -> list[DiscoveredModel]:
+ """Discover models via ``GET {base_url}/models`` (OpenAI-compatible)."""
+ headers = self._build_discovery_headers(
+ api_key=api_key,
+ api_key_header=api_key_header,
+ custom_headers=custom_headers,
+ )
+ data = await self._get_json(f"{base_url.rstrip('/')}/models", headers)
+ if isinstance(data, dict) and isinstance(data.get("data"), list):
+ raw_items = [item for item in data["data"] if isinstance(item, dict)]
+ elif isinstance(data, list):
+ raw_items = [item for item in data if isinstance(item, dict)]
+ else:
+ raise ValueError(f"Unexpected response format: {type(data)}")
+
+ discovered: list[DiscoveredModel] = []
+ for raw in raw_items:
+ model_name = raw.get("id") or raw.get("model_name")
+ if not isinstance(model_name, str):
+ continue
+ discovered.append(
+ DiscoveredModel(model_name=model_name, metadata=dict(raw))
)
- return response.status_code == 200
- except Exception:
- return False
+ return discovered
- async def _discover_models(
+ _ollama_gateway_root = staticmethod(ollama_gateway_root)
+
+ async def _discover_ollama_models(
self,
base_url: str,
+ *,
api_key: str | None = None,
custom_headers: dict[str, str] | None = None,
api_key_header: str | None = None,
- ) -> list[dict[str, object]]:
- """Discover available models from a provider endpoint."""
- try:
- response = await self._fetch_models(
- base_url=base_url,
- api_key=api_key,
- api_key_header=api_key_header,
- custom_headers=custom_headers,
- timeout=30.0,
+ ) -> list[DiscoveredModel]:
+ """Discover models via ``GET {gateway_root}/api/tags`` (Ollama).
+
+ Stores each model's ``digest`` in metadata.
+ """
+ headers = self._build_discovery_headers(
+ api_key=api_key,
+ api_key_header=api_key_header,
+ custom_headers=custom_headers,
+ )
+ gateway_root = self._ollama_gateway_root(base_url)
+ data = await self._get_json(f"{gateway_root}/api/tags", headers)
+ if not isinstance(data, dict) or not isinstance(data.get("models"), list):
+ raise ValueError(f"Unexpected response format: {type(data)}")
+
+ discovered: list[DiscoveredModel] = []
+ for raw in data["models"]:
+ if not isinstance(raw, dict):
+ continue
+ model_name = raw.get("name")
+ if not isinstance(model_name, str):
+ continue
+ digest = raw.get("digest")
+ digest_str = digest if isinstance(digest, str) else None
+ discovered.append(
+ DiscoveredModel(
+ model_name=model_name,
+ metadata={"digest": digest_str},
+ )
)
- response.raise_for_status()
- data = response.json()
- except httpx.HTTPError as err:
- raise ValueError(f"Failed to discover models: {err}") from err
- if isinstance(data, dict) and isinstance(data.get("data"), list):
- return [item for item in data["data"] if isinstance(item, dict)]
- if isinstance(data, list):
- return [item for item in data if isinstance(item, dict)]
- raise ValueError(f"Unexpected response format: {type(data)}")
+ return discovered
diff --git a/tracecat/agent/provider/types.py b/tracecat/agent/provider/types.py
index be105e3399..a5bec29b91 100644
--- a/tracecat/agent/provider/types.py
+++ b/tracecat/agent/provider/types.py
@@ -1,6 +1,15 @@
"""Domain types for LLM provider management."""
from dataclasses import dataclass
+from enum import StrEnum
+
+
+class CustomProviderType(StrEnum):
+ """Explicit provider type driving discovery and validation."""
+
+ GENERIC_OPENAI_COMPATIBLE = "generic_openai_compatible"
+ LITELLM = "litellm"
+ OLLAMA = "ollama"
@dataclass(kw_only=True, slots=True)
@@ -9,3 +18,19 @@ class ResolvedCustomProviderCredentials:
api_key: str | None = None
custom_headers: dict[str, str] | None = None
+
+
+def ollama_gateway_root(base_url: str) -> str:
+ """Strip a single trailing ``/v1`` suffix (Ollama native root). Idempotent."""
+ trimmed = base_url.rstrip("/")
+ if trimmed.endswith("/v1"):
+ return trimmed[: -len("/v1")]
+ return trimmed
+
+
+def ensure_ollama_v1(base_url: str) -> str:
+ """Ensure a single trailing ``/v1`` (Ollama OpenAI-compat surface). Idempotent."""
+ trimmed = base_url.rstrip("/")
+ if trimmed.endswith("/v1"):
+ return trimmed
+ return f"{trimmed}/v1"
diff --git a/tracecat/agent/service.py b/tracecat/agent/service.py
index a1c541f221..e81019a56e 100644
--- a/tracecat/agent/service.py
+++ b/tracecat/agent/service.py
@@ -23,6 +23,7 @@
from tracecat.agent.catalog.schemas import AgentCatalogRead
from tracecat.agent.config import MODEL_CONFIGS, PROVIDER_CREDENTIAL_CONFIGS
from tracecat.agent.preset.service import AgentPresetService
+from tracecat.agent.provider.types import CustomProviderType, ensure_ollama_v1
from tracecat.agent.schemas import (
DefaultModelSelection,
ModelConfig,
@@ -649,6 +650,21 @@ async def get_catalog_credentials(
credentials["CUSTOM_MODEL_PROVIDER_PASSTHROUGH"] = (
"true" if provider_row.passthrough else "false"
)
+ # Type marker lets the gateway drop thinking and sanitize replayed
+ # messages for Ollama's strict /v1 parser.
+ credentials["CUSTOM_MODEL_PROVIDER_TYPE"] = provider_row.type
+ if provider_row.type == CustomProviderType.OLLAMA.value:
+ # Ollama needs no key, but the OpenAI-compatible client requires
+ # one. Inject a placeholder when none is stored.
+ if not credentials.get("CUSTOM_MODEL_PROVIDER_API_KEY"):
+ credentials["CUSTOM_MODEL_PROVIDER_API_KEY"] = "ollama"
+ # Ollama's OpenAI-compatible chat surface lives only under /v1;
+ # the stored base_url is a bare server root. Ensure /v1 for
+ # runtime use without mutating what is persisted.
+ if base_url := credentials.get("CUSTOM_MODEL_PROVIDER_BASE_URL"):
+ credentials["CUSTOM_MODEL_PROVIDER_BASE_URL"] = ensure_ollama_v1(
+ base_url
+ )
# Pin the selected row's model_name so the shared provider blob
# can't override it. The legacy backfill row is the exception: its
# "custom" placeholder isn't a real model, so leave the blob's
diff --git a/tracecat/db/models.py b/tracecat/db/models.py
index 091ec48420..fb57a966f3 100644
--- a/tracecat/db/models.py
+++ b/tracecat/db/models.py
@@ -2712,6 +2712,12 @@ class AgentCustomProvider(OrganizationModel):
)
display_name: Mapped[str] = mapped_column(String(200), nullable=False)
base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ # Plain string column (not a DB enum), mirroring AgentCatalog.model_provider.
+ type: Mapped[str] = mapped_column(
+ String(120),
+ nullable=False,
+ server_default=text("'generic_openai_compatible'"),
+ )
passthrough: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false")
)