first commit
This commit is contained in:
418
ui/app/workspace/providers/fragments/betaHeadersFormFragment.tsx
Normal file
418
ui/app/workspace/providers/fragments/betaHeadersFormFragment.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getErrorMessage, setProviderFormDirtyState, useAppDispatch } from "@/lib/store";
|
||||
import { useUpdateProviderMutation } from "@/lib/store/apis/providersApi";
|
||||
import { ModelProvider, NetworkConfig } from "@/lib/types/config";
|
||||
import { buildProviderUpdatePayload } from "@/app/workspace/providers/views/utils";
|
||||
import { betaHeadersFormSchema, type BetaHeadersFormSchema } from "@/lib/types/schemas";
|
||||
import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Known beta headers with their prefixes, descriptions, and default support per provider.
|
||||
// This mirrors the Go ProviderFeatures map in core/providers/anthropic/types.go.
|
||||
const KNOWN_BETA_HEADERS = [
|
||||
{
|
||||
prefix: "computer-use-",
|
||||
label: "Computer Use",
|
||||
description: "Computer use client tool",
|
||||
defaults: { anthropic: true, vertex: true, bedrock: true, azure: true },
|
||||
},
|
||||
{
|
||||
prefix: "structured-outputs-",
|
||||
label: "Structured Outputs",
|
||||
description: "Strict tool validation and output_format",
|
||||
defaults: { anthropic: true, vertex: false, bedrock: true, azure: true },
|
||||
},
|
||||
{
|
||||
prefix: "advanced-tool-use-",
|
||||
label: "Advanced Tool Use",
|
||||
description: "defer_loading, input_examples, allowed_callers",
|
||||
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
|
||||
},
|
||||
{
|
||||
prefix: "mcp-client-",
|
||||
label: "MCP Client",
|
||||
description: "MCP connector support",
|
||||
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
|
||||
},
|
||||
{
|
||||
prefix: "prompt-caching-scope-",
|
||||
label: "Prompt Caching Scope",
|
||||
description: "Prompt caching scope control",
|
||||
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
|
||||
},
|
||||
{
|
||||
prefix: "compact-",
|
||||
label: "Compaction",
|
||||
description: "Server-side context compaction",
|
||||
defaults: { anthropic: true, vertex: true, bedrock: true, azure: true },
|
||||
},
|
||||
{
|
||||
prefix: "context-management-",
|
||||
label: "Context Management",
|
||||
description: "Context editing (clear_tool_uses, clear_thinking)",
|
||||
defaults: { anthropic: true, vertex: true, bedrock: true, azure: true },
|
||||
},
|
||||
{
|
||||
prefix: "files-api-",
|
||||
label: "Files API",
|
||||
description: "Files API support",
|
||||
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
|
||||
},
|
||||
{
|
||||
prefix: "interleaved-thinking-",
|
||||
label: "Interleaved Thinking",
|
||||
description: "Interleaved thinking between tool calls",
|
||||
defaults: { anthropic: true, vertex: true, bedrock: true, azure: true },
|
||||
},
|
||||
{
|
||||
prefix: "skills-",
|
||||
label: "Skills",
|
||||
description: "Agent Skills",
|
||||
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
|
||||
},
|
||||
{
|
||||
prefix: "context-1m-",
|
||||
label: "Context 1M",
|
||||
description: "1M context window (beta for Sonnet 4.5/4)",
|
||||
defaults: { anthropic: true, vertex: true, bedrock: true, azure: true },
|
||||
},
|
||||
{
|
||||
prefix: "fast-mode-",
|
||||
label: "Fast Mode",
|
||||
description: "Fast mode (Opus 4.6 research preview)",
|
||||
defaults: { anthropic: true, vertex: false, bedrock: false, azure: false },
|
||||
},
|
||||
{
|
||||
prefix: "redact-thinking-",
|
||||
label: "Redact Thinking",
|
||||
description: "Redact thinking blocks in responses",
|
||||
defaults: { anthropic: true, vertex: false, bedrock: false, azure: true },
|
||||
},
|
||||
] as const;
|
||||
|
||||
const KNOWN_PREFIXES = new Set<string>(KNOWN_BETA_HEADERS.map((h) => h.prefix));
|
||||
|
||||
type ProviderKey = "anthropic" | "vertex" | "bedrock" | "azure";
|
||||
|
||||
const ANTHROPIC_FAMILY_PROVIDERS: ProviderKey[] = ["anthropic", "vertex", "bedrock", "azure"];
|
||||
|
||||
function getProviderKey(providerName: string): ProviderKey | null {
|
||||
const name = providerName.toLowerCase();
|
||||
if (ANTHROPIC_FAMILY_PROVIDERS.includes(name as ProviderKey)) {
|
||||
return name as ProviderKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface BetaHeadersFormFragmentProps {
|
||||
provider: ModelProvider;
|
||||
}
|
||||
|
||||
export function BetaHeadersFormFragment({ provider }: BetaHeadersFormFragmentProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const hasUpdateProviderAccess = useRbac(RbacResource.ModelProvider, RbacOperation.Update);
|
||||
const [updateProvider, { isLoading: isUpdatingProvider }] = useUpdateProviderMutation();
|
||||
const providerKey = getProviderKey(provider.name);
|
||||
const [newPrefix, setNewPrefix] = useState("");
|
||||
const [newPrefixError, setNewPrefixError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<BetaHeadersFormSchema, any, BetaHeadersFormSchema>({
|
||||
resolver: zodResolver(betaHeadersFormSchema) as Resolver<BetaHeadersFormSchema, any, BetaHeadersFormSchema>,
|
||||
mode: "onChange",
|
||||
reValidateMode: "onChange",
|
||||
defaultValues: {
|
||||
beta_header_overrides: provider.network_config?.beta_header_overrides ?? {},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
beta_header_overrides: provider.network_config?.beta_header_overrides ?? {},
|
||||
});
|
||||
}, [form, provider.name, provider.network_config?.beta_header_overrides]);
|
||||
|
||||
const overrides = form.watch("beta_header_overrides") ?? {};
|
||||
|
||||
// Manual dirty tracking — RHF's deep equality on records is unreliable with setValue
|
||||
const savedOverrides = provider.network_config?.beta_header_overrides ?? {};
|
||||
const isManuallyDirty = useMemo(() => {
|
||||
const currentKeys = Object.keys(overrides);
|
||||
const savedKeys = Object.keys(savedOverrides);
|
||||
if (currentKeys.length !== savedKeys.length) return true;
|
||||
return currentKeys.some((key) => overrides[key] !== savedOverrides[key]);
|
||||
}, [overrides, savedOverrides]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setProviderFormDirtyState(isManuallyDirty));
|
||||
}, [isManuallyDirty, dispatch]);
|
||||
|
||||
// Custom prefixes are overrides that don't match any known prefix
|
||||
const customPrefixes = useMemo(() => {
|
||||
return Object.keys(overrides).filter((prefix) => !KNOWN_PREFIXES.has(prefix));
|
||||
}, [overrides]);
|
||||
|
||||
const headerRows = useMemo(() => {
|
||||
if (!providerKey) return [];
|
||||
return KNOWN_BETA_HEADERS.map((header) => {
|
||||
const defaultSupported = header.defaults[providerKey];
|
||||
const override = overrides[header.prefix];
|
||||
return { ...header, defaultSupported, override };
|
||||
});
|
||||
}, [providerKey, overrides]);
|
||||
|
||||
const onSubmit = (data: BetaHeadersFormSchema) => {
|
||||
const cleanedOverrides: Record<string, boolean> = {};
|
||||
if (data.beta_header_overrides) {
|
||||
for (const [prefix, value] of Object.entries(data.beta_header_overrides)) {
|
||||
cleanedOverrides[prefix] = value;
|
||||
}
|
||||
}
|
||||
|
||||
updateProvider(
|
||||
buildProviderUpdatePayload(provider, {
|
||||
network_config: {
|
||||
...(provider.network_config ?? ({} as NetworkConfig)),
|
||||
beta_header_overrides: Object.keys(cleanedOverrides).length > 0 ? cleanedOverrides : undefined,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.unwrap()
|
||||
.then(() => {
|
||||
toast.success("Beta header configuration updated successfully");
|
||||
form.reset(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error("Failed to update beta header configuration", {
|
||||
description: getErrorMessage(err),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const setOverride = useCallback(
|
||||
(prefix: string, value: "default" | "enabled" | "disabled") => {
|
||||
const current = form.getValues("beta_header_overrides") ?? {};
|
||||
const updated = { ...current };
|
||||
if (value === "default") {
|
||||
delete updated[prefix];
|
||||
} else {
|
||||
updated[prefix] = value === "enabled";
|
||||
}
|
||||
form.setValue("beta_header_overrides", updated, { shouldDirty: true });
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const removeCustomPrefix = useCallback(
|
||||
(prefix: string) => {
|
||||
const current = form.getValues("beta_header_overrides") ?? {};
|
||||
const updated = { ...current };
|
||||
delete updated[prefix];
|
||||
form.setValue("beta_header_overrides", updated, { shouldDirty: true });
|
||||
},
|
||||
[form],
|
||||
);
|
||||
|
||||
const addCustomPrefix = useCallback(() => {
|
||||
let prefix = newPrefix.trim().toLowerCase();
|
||||
if (!prefix) return;
|
||||
|
||||
// Ensure prefix ends with "-"
|
||||
if (!prefix.endsWith("-")) {
|
||||
prefix = prefix + "-";
|
||||
}
|
||||
|
||||
// Validate
|
||||
if (KNOWN_PREFIXES.has(prefix)) {
|
||||
setNewPrefixError("This is a known header — use the override dropdown above instead");
|
||||
return;
|
||||
}
|
||||
if (overrides[prefix] !== undefined) {
|
||||
setNewPrefixError("This prefix already exists");
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9-]+$/.test(prefix)) {
|
||||
setNewPrefixError("Prefix must contain only lowercase letters, numbers, and hyphens");
|
||||
return;
|
||||
}
|
||||
|
||||
const current = form.getValues("beta_header_overrides") ?? {};
|
||||
form.setValue("beta_header_overrides", { ...current, [prefix]: true }, { shouldDirty: true });
|
||||
setNewPrefix("");
|
||||
setNewPrefixError(null);
|
||||
}, [newPrefix, overrides, form]);
|
||||
|
||||
const getSelectValue = (prefix: string): string => {
|
||||
const override = overrides[prefix];
|
||||
if (override === undefined) return "default";
|
||||
return override ? "enabled" : "disabled";
|
||||
};
|
||||
|
||||
if (!providerKey) return null;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 px-6" data-testid="provider-config-beta-headers-content">
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Configure which Anthropic beta headers are allowed for this provider. Override the defaults when a provider adds or removes
|
||||
support for a beta feature.
|
||||
</p>
|
||||
<div className="rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="px-3 py-2 text-left font-medium">Beta Header</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Default</th>
|
||||
<th className="w-[180px] px-3 py-2 text-left font-medium">Override</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{headerRows.map((row) => (
|
||||
<tr key={row.prefix} className="border-b last:border-b-0">
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs">{row.prefix}*</span>
|
||||
<span className="text-muted-foreground text-xs">{row.description}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant={row.defaultSupported ? "default" : "secondary"} className="text-xs">
|
||||
{row.defaultSupported ? "Supported" : "Unsupported"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="w-[180px] px-3 py-2">
|
||||
<Select
|
||||
value={getSelectValue(row.prefix)}
|
||||
onValueChange={(val) => setOverride(row.prefix, val as "default" | "enabled" | "disabled")}
|
||||
disabled={!hasUpdateProviderAccess}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 text-xs"
|
||||
data-testid={`provider-beta-override-select-${row.prefix.replace(/-/g, "")}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default</SelectItem>
|
||||
<SelectItem value="enabled">Supported</SelectItem>
|
||||
<SelectItem value="disabled">Unsupported</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{customPrefixes.map((prefix) => (
|
||||
<tr key={prefix} className="border-b last:border-b-0">
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-mono text-xs">{prefix}*</span>
|
||||
<span className="text-muted-foreground text-xs">Custom header</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Custom
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="w-[180px] px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Select
|
||||
value={overrides[prefix] ? "enabled" : "disabled"}
|
||||
onValueChange={(val) => setOverride(prefix, val as "enabled" | "disabled")}
|
||||
disabled={!hasUpdateProviderAccess}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 text-xs"
|
||||
data-testid={`provider-beta-custom-override-select-${prefix.replace(/-/g, "")}`}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="enabled">Supported</SelectItem>
|
||||
<SelectItem value="disabled">Unsupported</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={!hasUpdateProviderAccess}
|
||||
onClick={() => removeCustomPrefix(prefix)}
|
||||
data-testid={`provider-beta-remove-prefix-btn-${prefix.replace(/-/g, "")}`}
|
||||
aria-label={`Remove custom prefix ${prefix}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 pt-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Add custom beta header prefix (e.g. new-feature-)"
|
||||
value={newPrefix}
|
||||
onChange={(e) => {
|
||||
setNewPrefix(e.target.value);
|
||||
setNewPrefixError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addCustomPrefix();
|
||||
}
|
||||
}}
|
||||
disabled={!hasUpdateProviderAccess}
|
||||
className="h-8 text-xs"
|
||||
data-testid="provider-beta-custom-prefix-input"
|
||||
aria-label="Custom beta header prefix"
|
||||
aria-describedby={newPrefixError ? "custom-prefix-error" : undefined}
|
||||
/>
|
||||
{newPrefixError && (
|
||||
<p className="text-destructive mt-1 text-xs" id="custom-prefix-error">
|
||||
{newPrefixError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!hasUpdateProviderAccess || !newPrefix.trim()}
|
||||
onClick={addCustomPrefix}
|
||||
data-testid="provider-beta-add-prefix-btn"
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2 pb-6">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isManuallyDirty || !hasUpdateProviderAccess || isUpdatingProvider}
|
||||
isLoading={isUpdatingProvider}
|
||||
data-testid="provider-beta-save-btn"
|
||||
>
|
||||
Save Beta Header Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user