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. + + + +
+
{ + if (form.formState.errors.customHeadersJson) setAdvancedOpen(true) + })} + className="space-y-4" + > + ( + + Type + + + + )} + /> + + ( + + Name + + + + + + )} + /> + + + + + + + + + +
+
+
+
+ ) +} 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 ( +
+
+ ( + + Auth header + + + + + + )} + /> + ( + + Auth value + + + + + + )} + /> +
+

+ Defaults to{" "} + + Authorization: Bearer <value> + {" "} + if no header is set. +

+
+ ) +} + +/** + * 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 + +