support changing capabilities on provider integrations

This commit is contained in:
miloschwartz
2026-08-07 11:23:07 -04:00
parent 9e7b4afaec
commit d699455b38
8 changed files with 60 additions and 121 deletions
+1 -1
View File
@@ -1729,7 +1729,7 @@
"aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers", "aiProviderErrorRoutingModeTarget": "Site targets routing is only available for custom providers",
"aiProviderErrorCapabilitiesRequired": "Select at least one API capability", "aiProviderErrorCapabilitiesRequired": "Select at least one API capability",
"aiProviderCapabilities": "API Capabilities", "aiProviderCapabilities": "API Capabilities",
"aiProviderCapabilitiesDescription": "Which API formats this provider accepts. Built-in providers use fixed capabilities.", "aiProviderCapabilitiesDescription": "Select which API formats this provider can handle. Known providers start with recommended defaults.",
"aiProviderCapabilitiesCustomDescription": "Select which API formats this custom provider can handle", "aiProviderCapabilitiesCustomDescription": "Select which API formats this custom provider can handle",
"aiProviderCapabilitiesSelect": "Select capabilities", "aiProviderCapabilitiesSelect": "Select capabilities",
"aiProviderCapabilitiesEmpty": "No capabilities found", "aiProviderCapabilitiesEmpty": "No capabilities found",
+4 -1
View File
@@ -261,8 +261,11 @@ export function resolveCapabilitiesForCreate(input: {
type: AiProviderType; type: AiProviderType;
capabilities?: AiCapability[] | null; capabilities?: AiCapability[] | null;
}): AiCapability[] { }): AiCapability[] {
if (input.capabilities != null) {
return parseCapabilities(input.capabilities);
}
if (input.type === "custom") { if (input.type === "custom") {
return parseCapabilities(input.capabilities ?? []); return [];
} }
return [...AI_PROVIDER_CAPABILITY_DEFAULTS[input.type]]; return [...AI_PROVIDER_CAPABILITY_DEFAULTS[input.type]];
} }
@@ -124,6 +124,15 @@ export async function createAiProvider(
capabilities capabilities
}); });
if (resolvedCapabilities.length === 0) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"At least one capability is required"
)
);
}
const [provider] = await db const [provider] = await db
.insert(aiProviders) .insert(aiProviders)
.values({ .values({
+3 -14
View File
@@ -131,20 +131,9 @@ export async function updateAiProvider(
? body.authType ? body.authType
: (existing.authType as AiProviderAuthType); : (existing.authType as AiProviderAuthType);
if (body.capabilities !== undefined && providerType !== "custom") {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Capabilities can only be updated for custom providers"
)
);
}
const nextCapabilities = const nextCapabilities =
providerType === "custom" body.capabilities !== undefined
? body.capabilities !== undefined ? body.capabilities
? body.capabilities
: parseCapabilities(existing.capabilities)
: parseCapabilities(existing.capabilities); : parseCapabilities(existing.capabilities);
const validation = z const validation = z
@@ -195,7 +184,7 @@ export async function updateAiProvider(
if (body.authType !== undefined) { if (body.authType !== undefined) {
updateData.authType = body.authType; updateData.authType = body.authType;
} }
if (providerType === "custom" && body.capabilities !== undefined) { if (body.capabilities !== undefined) {
updateData.capabilities = serializeCapabilities(body.capabilities); updateData.capabilities = serializeCapabilities(body.capabilities);
} }
+10
View File
@@ -112,5 +112,15 @@ export function refineProviderUpstreamFields(
path: ["capabilities"] path: ["capabilities"]
}); });
} }
} else if (
data.capabilities !== undefined &&
data.capabilities !== null &&
data.capabilities.length === 0
) {
ctx.addIssue({
code: "custom",
message: "At least one capability is required",
path: ["capabilities"]
});
} }
} }
@@ -12,10 +12,7 @@ import {
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect";
AiProviderCapabilitiesSelect,
capabilityLabelKey
} from "@app/components/AiProviderCapabilitiesSelect";
import { SwitchInput } from "@app/components/SwitchInput"; import { SwitchInput } from "@app/components/SwitchInput";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { import {
@@ -49,7 +46,6 @@ export default function AiProviderGeneralPage() {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false); const [saveLoading, setSaveLoading] = useState(false);
const isCustom = provider.type === "custom";
const generalSchema = useMemo( const generalSchema = useMemo(
() => () =>
@@ -63,10 +59,7 @@ export default function AiProviderGeneralPage() {
capabilities: z.array(z.enum(AI_CAPABILITIES)).optional() capabilities: z.array(z.enum(AI_CAPABILITIES)).optional()
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if ( if (!data.capabilities || data.capabilities.length === 0) {
isCustom &&
(!data.capabilities || data.capabilities.length === 0)
) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: t("aiProviderErrorCapabilitiesRequired"), message: t("aiProviderErrorCapabilitiesRequired"),
@@ -74,7 +67,7 @@ export default function AiProviderGeneralPage() {
}); });
} }
}), }),
[t, isCustom] [t]
); );
type GeneralFormValues = z.infer<typeof generalSchema>; type GeneralFormValues = z.infer<typeof generalSchema>;
@@ -97,11 +90,9 @@ export default function AiProviderGeneralPage() {
capabilities?: AiCapability[]; capabilities?: AiCapability[];
} = { } = {
name: values.name.trim(), name: values.name.trim(),
enabled: values.enabled enabled: values.enabled,
capabilities: values.capabilities ?? []
}; };
if (isCustom) {
body.capabilities = values.capabilities ?? [];
}
const res = await api.post< const res = await api.post<
AxiosResponse<CreateOrEditAiProviderResponse> AxiosResponse<CreateOrEditAiProviderResponse>
@@ -211,46 +202,20 @@ export default function AiProviderGeneralPage() {
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
{isCustom ? ( <AiProviderCapabilitiesSelect
<AiProviderCapabilitiesSelect value={
value={ field.value ??
field.value ?? []
[] }
} onChange={
onChange={ field.onChange
field.onChange }
} />
/>
) : (
<div className="flex flex-wrap gap-2">
{(
provider.capabilities ??
[]
).map((cap) => (
<span
key={
cap
}
className="inline-flex items-center rounded-md border border-input bg-muted/40 px-2.5 py-1 text-sm"
>
{t(
capabilityLabelKey(
cap
)
)}
</span>
))}
</div>
)}
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{isCustom {t(
? t( "aiProviderCapabilitiesDescription"
"aiProviderCapabilitiesCustomDescription" )}
)
: t(
"aiProviderCapabilitiesDescription"
)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -20,10 +20,7 @@ import {
} from "@app/components/Settings"; } from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle"; import HeaderTitle from "@app/components/SettingsSectionTitle";
import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect"; import { AiProviderAuthTypeSelect } from "@app/components/AiProviderAuthTypeSelect";
import { import { AiProviderCapabilitiesSelect } from "@app/components/AiProviderCapabilitiesSelect";
AiProviderCapabilitiesSelect,
capabilityLabelKey
} from "@app/components/AiProviderCapabilitiesSelect";
import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect"; import { AiProviderTypeSelect } from "@app/components/AiProviderTypeSelect";
import { HeadersInput } from "@app/components/HeadersInput"; import { HeadersInput } from "@app/components/HeadersInput";
import { StrategySelect } from "@app/components/StrategySelect"; import { StrategySelect } from "@app/components/StrategySelect";
@@ -93,14 +90,12 @@ export default function CreateAiProviderPage() {
const providerType = form.watch("type"); const providerType = form.watch("type");
const routingMode = form.watch("routingMode"); const routingMode = form.watch("routingMode");
const authType = form.watch("authType"); const authType = form.watch("authType");
const capabilities = form.watch("capabilities");
const showUpstream = showsUpstreamUrlField(providerType, routingMode); const showUpstream = showsUpstreamUrlField(providerType, routingMode);
const requireUpstream = upstreamUrlRequired(providerType, routingMode); const requireUpstream = upstreamUrlRequired(providerType, routingMode);
const showRoutingMode = providerType === "custom"; const showRoutingMode = providerType === "custom";
const showTargets = providerType === "custom" && routingMode === "target"; const showTargets = providerType === "custom" && routingMode === "target";
const showApiKey = authTypeRequiresApiKey(authType ?? "bearer"); const showApiKey = authTypeRequiresApiKey(authType ?? "bearer");
const showCapabilitiesSelect = providerType === "custom";
async function createTargets( async function createTargets(
providerId: number, providerId: number,
@@ -326,48 +321,20 @@ export default function CreateAiProviderPage() {
)} )}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
{showCapabilitiesSelect ? ( <AiProviderCapabilitiesSelect
<AiProviderCapabilitiesSelect value={
value={ field.value ??
field.value ?? []
[] }
} onChange={
onChange={ field.onChange
field.onChange }
} />
/>
) : (
<div className="flex flex-wrap gap-2">
{(
capabilities ??
defaultCapabilitiesForProvider(
providerType
)
).map((cap) => (
<span
key={
cap
}
className="inline-flex items-center rounded-md border border-input bg-muted/40 px-2.5 py-1 text-sm"
>
{t(
capabilityLabelKey(
cap
)
)}
</span>
))}
</div>
)}
</FormControl> </FormControl>
<FormDescription> <FormDescription>
{showCapabilitiesSelect {t(
? t( "aiProviderCapabilitiesDescription"
"aiProviderCapabilitiesCustomDescription" )}
)
: t(
"aiProviderCapabilitiesDescription"
)}
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
+3 -7
View File
@@ -97,10 +97,7 @@ export function createAiProviderFormSchema(t: TranslateFn) {
}); });
} }
if ( if (!data.capabilities || data.capabilities.length === 0) {
data.type === "custom" &&
(!data.capabilities || data.capabilities.length === 0)
) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: t("aiProviderErrorCapabilitiesRequired"), message: t("aiProviderErrorCapabilitiesRequired"),
@@ -187,8 +184,7 @@ export function toAiProviderCreatePayload(values: AiProviderFormValues) {
upstreamUrl, upstreamUrl,
apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined, apiKey: values.apiKey?.trim() ? values.apiKey.trim() : undefined,
authType: values.authType ?? "bearer", authType: values.authType ?? "bearer",
capabilities: capabilities: values.capabilities ?? [],
values.type === "custom" ? (values.capabilities ?? []) : undefined,
headers: headers:
values.headers && values.headers.length > 0 ? values.headers : null, values.headers && values.headers.length > 0 ? values.headers : null,
skipTlsVerification: values.skipTlsVerification, skipTlsVerification: values.skipTlsVerification,
@@ -216,7 +212,7 @@ export function toAiProviderUpdatePayload(values: AiProviderFormValues) {
enabled: values.enabled ?? true enabled: values.enabled ?? true
}; };
if (values.type === "custom" && values.capabilities) { if (values.capabilities) {
payload.capabilities = values.capabilities; payload.capabilities = values.capabilities;
} }