mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: refactor the agents admin UI layout (#22567)
I am working on a subsequent change to make the fields auto-generated with `make gen` from the Go code itself, rather than us needing to create a UI compatibility layer. Once the above is done, I'll be adding in the payload so users can very easily just click "Opus 4.6" to add the model, and the config values will be set appropriately. This is really just UI changes, nothing functionally should change here. But the code will be cleaned up a lot post the above changes. <img width="1197" height="978" alt="image" src="https://github.com/user-attachments/assets/45f9afff-89bb-47a6-b9a1-534f50a9676e" /> <img width="1180" height="949" alt="image" src="https://github.com/user-attachments/assets/b3fd963f-1c1d-4d2c-b501-ac8118b019ec" /> <img width="1185" height="957" alt="image" src="https://github.com/user-attachments/assets/08faca29-2b38-476a-adab-0bd8ab17ddcc" />
This commit is contained in:
@@ -159,6 +159,14 @@ export const updateChatProviderConfig = (queryClient: QueryClient) => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteChatProviderConfig = (queryClient: QueryClient) => ({
|
||||
mutationFn: (providerConfigId: string) =>
|
||||
API.deleteChatProviderConfig(providerConfigId),
|
||||
onSuccess: async () => {
|
||||
await invalidateChatConfigurationQueries(queryClient);
|
||||
},
|
||||
});
|
||||
|
||||
export const createChatModelConfig = (queryClient: QueryClient) => ({
|
||||
mutationFn: (req: TypesGen.CreateChatModelConfigRequest) =>
|
||||
API.createChatModelConfig(req),
|
||||
|
||||
@@ -217,27 +217,47 @@ export const EnvPresetProviders: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await body.findByRole("button", { name: /OpenAI/i }));
|
||||
|
||||
// Both providers should be visible in the list.
|
||||
await expect(
|
||||
await body.findByText("API key managed by environment variable."),
|
||||
).toBeVisible();
|
||||
expect(body.getByText("Anthropic")).toBeInTheDocument();
|
||||
await body.findByRole("button", { name: /OpenAI/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
body.getByText(
|
||||
"This provider API key is managed by an environment variable.",
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
body.getByText(
|
||||
body.getByRole("button", { name: /Anthropic/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Navigate to OpenAI detail view.
|
||||
await userEvent.click(body.getByRole("button", { name: /OpenAI/i }));
|
||||
|
||||
// In the detail view we should see the env-managed alert.
|
||||
await expect(
|
||||
await body.findByText(
|
||||
"This provider key is configured from deployment environment settings and cannot be edited in this UI.",
|
||||
),
|
||||
).toBeVisible();
|
||||
// No API key input or create button should be present.
|
||||
expect(body.queryByLabelText(/API key/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
body.queryByRole("button", {
|
||||
name: "Create provider config",
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Navigate back to the list.
|
||||
await userEvent.click(body.getByText("Back"));
|
||||
|
||||
// Verify Anthropic is visible in the list again.
|
||||
await expect(
|
||||
await body.findByRole("button", { name: /Anthropic/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Navigate to Anthropic detail view and verify it's also env-managed.
|
||||
await userEvent.click(body.getByRole("button", { name: /Anthropic/i }));
|
||||
await expect(
|
||||
await body.findByText(
|
||||
"This provider key is configured from deployment environment settings and cannot be edited in this UI.",
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -271,7 +291,7 @@ export const CreateAndUpdateProvider: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
// Expand the accordion.
|
||||
// Navigate to the OpenAI detail view.
|
||||
await userEvent.click(await body.findByRole("button", { name: /OpenAI/i }));
|
||||
|
||||
// Fill in form to create a provider config.
|
||||
@@ -299,26 +319,24 @@ export const CreateAndUpdateProvider: Story = {
|
||||
}),
|
||||
);
|
||||
|
||||
// After creation the form should switch to "Save changes".
|
||||
// After creation, queries refetch and the component re-keys
|
||||
// because providerConfig now exists. Navigate back to the list
|
||||
// and re-enter the detail view to interact with the updated form.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
body.getByRole("button", { name: "Save changes" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Update the display name and base URL.
|
||||
const displayNameInput = body.getByPlaceholderText(
|
||||
"Friendly provider label",
|
||||
);
|
||||
await userEvent.clear(displayNameInput);
|
||||
await userEvent.type(displayNameInput, "Primary OpenAI");
|
||||
// The form was re-rendered with the new providerConfig.
|
||||
// Focus the API key field, type a new key, update the base URL,
|
||||
// and save.
|
||||
const apiKeyInput = body.getByLabelText(/API key/i);
|
||||
await userEvent.clear(apiKeyInput);
|
||||
await userEvent.type(apiKeyInput, "sk-updated-provider-key");
|
||||
const baseURLInput = body.getByLabelText("Base URL");
|
||||
await userEvent.clear(baseURLInput);
|
||||
await userEvent.type(baseURLInput, "https://internal-proxy.example.com/v2");
|
||||
await userEvent.type(
|
||||
body.getByLabelText(/API key/i),
|
||||
"sk-updated-provider-key",
|
||||
);
|
||||
await userEvent.click(body.getByRole("button", { name: "Save changes" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -327,7 +345,6 @@ export const CreateAndUpdateProvider: Story = {
|
||||
expect(API.updateChatProviderConfig).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
display_name: "Primary OpenAI",
|
||||
api_key: "sk-updated-provider-key",
|
||||
base_url: "https://internal-proxy.example.com/v2",
|
||||
}),
|
||||
@@ -337,60 +354,26 @@ export const CreateAndUpdateProvider: Story = {
|
||||
|
||||
// ── Models section stories ─────────────────────────────────────
|
||||
|
||||
export const ProviderSpecificModelConfigSchema: Story = {
|
||||
args: { section: "models" as ChatModelAdminSection },
|
||||
beforeEach: () => {
|
||||
setupChatSpies({
|
||||
providerConfigs: [
|
||||
createProviderConfig({
|
||||
id: "provider-openai",
|
||||
provider: "openai",
|
||||
display_name: "OpenAI",
|
||||
source: "database",
|
||||
has_api_key: true,
|
||||
}),
|
||||
createProviderConfig({
|
||||
id: "provider-anthropic",
|
||||
provider: "anthropic",
|
||||
display_name: "Anthropic",
|
||||
source: "database",
|
||||
has_api_key: true,
|
||||
}),
|
||||
],
|
||||
modelConfigs: [],
|
||||
modelCatalog: { providers: [] },
|
||||
/**
|
||||
* Helper to open the "Add model" dropdown and select a provider.
|
||||
* The "Add model" button is a DropdownMenuTrigger. Clicking it opens
|
||||
* a dropdown of addable providers. We then select the given provider.
|
||||
*/
|
||||
const openAddModelForm = async (
|
||||
body: ReturnType<typeof within>,
|
||||
providerLabel: string,
|
||||
) => {
|
||||
// Click the dropdown trigger to open the provider menu.
|
||||
const trigger = await body.findByRole("button", { name: "Add model" });
|
||||
await userEvent.click(trigger);
|
||||
// Radix portals dropdown content into the document body.
|
||||
// Wait for the menu to appear and click the provider item.
|
||||
await waitFor(async () => {
|
||||
const item = body.getByRole("menuitem", {
|
||||
name: new RegExp(providerLabel, "i"),
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
await userEvent.click(
|
||||
await body.findByRole("button", { name: "Add model" }),
|
||||
);
|
||||
|
||||
const schemaBlock = await body.findByTestId("chat-model-config-schema");
|
||||
expect(schemaBlock).toHaveTextContent('"provider": "openai"');
|
||||
expect(schemaBlock).toHaveTextContent('"openai": {');
|
||||
expect(schemaBlock).toHaveTextContent('"reasoning_effort": "high"');
|
||||
|
||||
// Switch provider to Anthropic.
|
||||
await userEvent.click(body.getByRole("combobox", { name: "Provider" }));
|
||||
await userEvent.click(
|
||||
await body.findByRole("option", { name: /Anthropic/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent(
|
||||
'"provider": "anthropic"',
|
||||
);
|
||||
});
|
||||
expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent(
|
||||
'"anthropic": {',
|
||||
);
|
||||
expect(body.getByTestId("chat-model-config-schema")).toHaveTextContent(
|
||||
'"thinking": {',
|
||||
);
|
||||
},
|
||||
await userEvent.click(item);
|
||||
});
|
||||
};
|
||||
|
||||
export const NoModelConfigByDefault: Story = {
|
||||
@@ -413,16 +396,19 @@ export const NoModelConfigByDefault: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
await userEvent.click(
|
||||
await body.findByRole("button", { name: "Add model" }),
|
||||
);
|
||||
await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro");
|
||||
// Open "Add model" dropdown and select the OpenAI provider.
|
||||
await openAddModelForm(body, "OpenAI");
|
||||
|
||||
await userEvent.type(body.getByLabelText(/Model Identifier/i), "gpt-5-pro");
|
||||
await userEvent.type(body.getByLabelText(/Context limit/i), "200000");
|
||||
|
||||
// Max output tokens is under the "Advanced" toggle.
|
||||
await userEvent.click(body.getByText("Advanced"));
|
||||
await expect(await body.findByLabelText(/Max output tokens/i)).toHaveValue(
|
||||
"",
|
||||
);
|
||||
|
||||
// The submit button in ModelForm also says "Add model".
|
||||
await userEvent.click(body.getByRole("button", { name: "Add model" }));
|
||||
await waitFor(() => {
|
||||
expect(API.createChatModelConfig).toHaveBeenCalledTimes(1);
|
||||
@@ -461,18 +447,23 @@ export const SubmitModelConfigExplicitly: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
await userEvent.click(
|
||||
await body.findByRole("button", { name: "Add model" }),
|
||||
// Open "Add model" dropdown and select the OpenAI provider.
|
||||
await openAddModelForm(body, "OpenAI");
|
||||
|
||||
await userEvent.type(
|
||||
body.getByLabelText(/Model Identifier/i),
|
||||
"gpt-5-pro-custom",
|
||||
);
|
||||
await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro-custom");
|
||||
await userEvent.type(body.getByLabelText(/Context limit/i), "200000");
|
||||
// Max output tokens and provider options are under "Advanced".
|
||||
await userEvent.click(body.getByText("Advanced"));
|
||||
await userEvent.type(
|
||||
await body.findByLabelText(/Max output tokens/i),
|
||||
"32000",
|
||||
);
|
||||
await userEvent.click(
|
||||
body.getByRole("combobox", {
|
||||
name: "Reasoning effort",
|
||||
name: "Reasoning Effort",
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await body.findByRole("option", { name: "high" }));
|
||||
@@ -518,11 +509,13 @@ export const ValidatesModelConfigFields: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
|
||||
await userEvent.click(
|
||||
await body.findByRole("button", { name: "Add model" }),
|
||||
);
|
||||
await userEvent.type(body.getByLabelText(/Model ID/i), "gpt-5-pro");
|
||||
// Open "Add model" dropdown and select the OpenAI provider.
|
||||
await openAddModelForm(body, "OpenAI");
|
||||
|
||||
await userEvent.type(body.getByLabelText(/Model Identifier/i), "gpt-5-pro");
|
||||
await userEvent.type(body.getByLabelText(/Context limit/i), "200000");
|
||||
// Max output tokens is under the "Advanced" toggle.
|
||||
await userEvent.click(body.getByText("Advanced"));
|
||||
const maxOutputTokensInput =
|
||||
await body.findByLabelText(/Max output tokens/i);
|
||||
await userEvent.type(maxOutputTokensInput, "not-a-number");
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createChatModelConfig as createChatModelConfigMutation,
|
||||
createChatProviderConfig as createChatProviderConfigMutation,
|
||||
deleteChatModelConfig as deleteChatModelConfigMutation,
|
||||
deleteChatProviderConfig as deleteChatProviderConfigMutation,
|
||||
updateChatModelConfig as updateChatModelConfigMutation,
|
||||
updateChatProviderConfig as updateChatProviderConfigMutation,
|
||||
} from "api/queries/chats";
|
||||
@@ -201,11 +202,13 @@ const useProviderStates = (
|
||||
type ChatModelAdminPanelProps = {
|
||||
className?: string;
|
||||
section?: ChatModelAdminSection;
|
||||
sectionLabel?: string;
|
||||
};
|
||||
|
||||
export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
||||
className,
|
||||
section = "providers",
|
||||
sectionLabel,
|
||||
}) => {
|
||||
const queryClient = useQueryClient();
|
||||
const [requestedProvider, setRequestedProvider] = useState<string | null>(
|
||||
@@ -230,6 +233,9 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
||||
const updateModelMut = useMutation(
|
||||
updateChatModelConfigMutation(queryClient),
|
||||
);
|
||||
const deleteProviderMut = useMutation(
|
||||
deleteChatProviderConfigMutation(queryClient),
|
||||
);
|
||||
const deleteModelMut = useMutation(
|
||||
deleteChatModelConfigMutation(queryClient),
|
||||
);
|
||||
@@ -281,30 +287,68 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
||||
const providerConfigsUnavailable = providerConfigsQuery.data === null;
|
||||
const modelConfigsUnavailable = modelConfigsQuery.data === null;
|
||||
const isProviderMutationPending =
|
||||
createProviderMut.isPending || updateProviderMut.isPending;
|
||||
createProviderMut.isPending ||
|
||||
updateProviderMut.isPending ||
|
||||
deleteProviderMut.isPending;
|
||||
const providerMutationError =
|
||||
createProviderMut.error ?? updateProviderMut.error;
|
||||
createProviderMut.error ??
|
||||
updateProviderMut.error ??
|
||||
deleteProviderMut.error;
|
||||
const modelMutationError =
|
||||
createModelMut.error ?? updateModelMut.error ?? deleteModelMut.error;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="m-0 text-[13px] leading-relaxed text-content-secondary">
|
||||
{section === "providers"
|
||||
? "Configure provider credentials and network settings."
|
||||
: "Manage models available in Agents across all providers."}
|
||||
</p>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-content-secondary">
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
Loading
|
||||
</div>
|
||||
<div className={cn("flex min-h-full flex-col space-y-3", className)}>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-content-secondary">
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
Loading
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 flex-col">
|
||||
{section === "providers" ? (
|
||||
<ProvidersSection
|
||||
sectionLabel={sectionLabel}
|
||||
providerStates={providerStates}
|
||||
providerConfigsUnavailable={providerConfigsUnavailable}
|
||||
isProviderMutationPending={isProviderMutationPending}
|
||||
onCreateProvider={(req) => createProviderMut.mutateAsync(req)}
|
||||
onUpdateProvider={(providerConfigId, req) =>
|
||||
updateProviderMut.mutateAsync({
|
||||
providerConfigId,
|
||||
req,
|
||||
})
|
||||
}
|
||||
onDeleteProvider={(id) => deleteProviderMut.mutateAsync(id)}
|
||||
onSelectedProviderChange={setRequestedProvider}
|
||||
/>
|
||||
) : (
|
||||
<ModelsSection
|
||||
sectionLabel={sectionLabel}
|
||||
providerStates={providerStates}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedProviderState={selectedProviderState}
|
||||
onSelectedProviderChange={setRequestedProvider}
|
||||
modelConfigs={modelConfigs}
|
||||
modelConfigsUnavailable={modelConfigsUnavailable}
|
||||
isCreating={createModelMut.isPending}
|
||||
isUpdating={updateModelMut.isPending}
|
||||
isDeleting={deleteModelMut.isPending}
|
||||
onCreateModel={(req) => createModelMut.mutateAsync(req)}
|
||||
onUpdateModel={(modelConfigId, req) =>
|
||||
updateModelMut.mutateAsync({
|
||||
modelConfigId,
|
||||
req,
|
||||
})
|
||||
}
|
||||
onDeleteModel={(id) => deleteModelMut.mutateAsync(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{/* Errors — rendered at the bottom */}
|
||||
{providerConfigsQuery.isError && (
|
||||
<ErrorAlert error={providerConfigsQuery.error} />
|
||||
)}
|
||||
@@ -318,7 +362,7 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
||||
{modelMutationError && <ErrorAlert error={modelMutationError} />}
|
||||
|
||||
{providerConfigsUnavailable && (
|
||||
<Alert severity="info" className="mb-3">
|
||||
<Alert severity="info">
|
||||
<AlertTitle>
|
||||
Chat provider admin API is unavailable on this deployment.
|
||||
</AlertTitle>
|
||||
@@ -327,50 +371,13 @@ export const ChatModelAdminPanel: FC<ChatModelAdminPanelProps> = ({
|
||||
)}
|
||||
|
||||
{modelConfigsUnavailable && (
|
||||
<Alert severity="info" className="mb-3">
|
||||
<Alert severity="info">
|
||||
<AlertTitle>
|
||||
Chat model admin API is unavailable on this deployment.
|
||||
</AlertTitle>
|
||||
<AlertDetail>/api/v2/chats/model-configs is missing.</AlertDetail>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{section === "providers" ? (
|
||||
<ProvidersSection
|
||||
providerStates={providerStates}
|
||||
providerConfigsUnavailable={providerConfigsUnavailable}
|
||||
isProviderMutationPending={isProviderMutationPending}
|
||||
onCreateProvider={(req) => createProviderMut.mutateAsync(req)}
|
||||
onUpdateProvider={(providerConfigId, req) =>
|
||||
updateProviderMut.mutateAsync({
|
||||
providerConfigId,
|
||||
req,
|
||||
})
|
||||
}
|
||||
onSelectedProviderChange={setRequestedProvider}
|
||||
/>
|
||||
) : (
|
||||
<ModelsSection
|
||||
providerStates={providerStates}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedProviderState={selectedProviderState}
|
||||
onSelectedProviderChange={setRequestedProvider}
|
||||
modelConfigs={modelConfigs}
|
||||
modelConfigsUnavailable={modelConfigsUnavailable}
|
||||
isCreating={createModelMut.isPending}
|
||||
isUpdating={updateModelMut.isPending}
|
||||
isDeleting={deleteModelMut.isPending}
|
||||
onCreateModel={(req) => createModelMut.mutateAsync(req)}
|
||||
onUpdateModel={(modelConfigId, req) =>
|
||||
updateModelMut.mutateAsync({
|
||||
modelConfigId,
|
||||
req,
|
||||
})
|
||||
}
|
||||
onDeleteModel={(id) => deleteModelMut.mutateAsync(id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ const InputField: FC<
|
||||
const fieldError = fieldErrors[fieldKey];
|
||||
const fieldProps = form.getFieldProps(fieldKey);
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={fieldKey}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
@@ -71,7 +71,7 @@ const InputField: FC<
|
||||
<Input
|
||||
id={fieldKey}
|
||||
className={cn(
|
||||
"h-10 text-[13px] placeholder:text-content-disabled",
|
||||
"h-9 min-w-0 text-[13px] placeholder:text-content-disabled",
|
||||
fieldError && "border-content-destructive",
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
@@ -100,7 +100,7 @@ const SelectField: FC<
|
||||
const fieldError = fieldErrors[fieldKey];
|
||||
const currentValue = (getIn(form.values, fieldKey) as string) || "";
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={fieldKey}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
@@ -120,16 +120,16 @@ const SelectField: FC<
|
||||
<SelectTrigger
|
||||
id={fieldKey}
|
||||
className={cn(
|
||||
"h-10 text-[13px]",
|
||||
"h-9 min-w-0 text-[13px]",
|
||||
fieldError && "border-content-destructive",
|
||||
)}
|
||||
aria-invalid={!!fieldError}
|
||||
aria-describedby={fieldError ? errorId : undefined}
|
||||
>
|
||||
<SelectValue placeholder="Use backend default" />
|
||||
<SelectValue placeholder="Unset" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={unsetSelectValue}>Use backend default</SelectItem>
|
||||
<SelectItem value={unsetSelectValue}>Unset</SelectItem>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
@@ -157,7 +157,7 @@ const JSONField: FC<
|
||||
const fieldError = fieldErrors[fieldKey];
|
||||
const fieldProps = form.getFieldProps(fieldKey);
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={fieldKey}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
@@ -187,243 +187,209 @@ const JSONField: FC<
|
||||
|
||||
// ── Provider-specific field sets ───────────────────────────────
|
||||
|
||||
const OpenAIFields: FC<FieldRenderContext & { sectionTitle: string }> = (
|
||||
props,
|
||||
) => (
|
||||
<div className="space-y-2">
|
||||
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
|
||||
{props.sectionTitle}
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openai.reasoningEffort"
|
||||
label="Reasoning effort"
|
||||
options={modelConfigReasoningEffortOptions}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openai.parallelToolCalls"
|
||||
label="Parallel tool calls"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openai.textVerbosity"
|
||||
label="Text verbosity"
|
||||
options={modelConfigTextVerbosityOptions}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openai.serviceTier"
|
||||
label="Service tier"
|
||||
placeholder="auto"
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openai.reasoningSummary"
|
||||
label="Reasoning summary"
|
||||
placeholder="detailed"
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openai.user"
|
||||
label="User"
|
||||
placeholder="end-user-id"
|
||||
/>
|
||||
</div>
|
||||
const OpenAIFields: FC<FieldRenderContext> = (props) => (
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openai.reasoningEffort"
|
||||
label="Reasoning Effort"
|
||||
options={modelConfigReasoningEffortOptions}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openai.parallelToolCalls"
|
||||
label="Parallel Tool Calls"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openai.textVerbosity"
|
||||
label="Text Verbosity"
|
||||
options={modelConfigTextVerbosityOptions}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openai.serviceTier"
|
||||
label="Service Tier"
|
||||
placeholder="auto"
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openai.reasoningSummary"
|
||||
label="Reasoning Summary"
|
||||
placeholder="detailed"
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openai.user"
|
||||
label="User"
|
||||
placeholder="end-user-id"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AnthropicFields: FC<FieldRenderContext & { sectionTitle: string }> = (
|
||||
props,
|
||||
) => (
|
||||
<div className="space-y-2">
|
||||
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
|
||||
{props.sectionTitle}
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.anthropic.effort"
|
||||
label="Output effort"
|
||||
options={modelConfigAnthropicEffortOptions}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.anthropic.thinkingBudgetTokens"
|
||||
label="Thinking budget tokens"
|
||||
placeholder="4000"
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.anthropic.sendReasoning"
|
||||
label="Send reasoning"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.anthropic.disableParallelToolUse"
|
||||
label="Disable parallel tool use"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
</div>
|
||||
const AnthropicFields: FC<FieldRenderContext> = (props) => (
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.anthropic.effort"
|
||||
label="Output Effort"
|
||||
options={modelConfigAnthropicEffortOptions}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.anthropic.thinkingBudgetTokens"
|
||||
label="Thinking Budget Tokens"
|
||||
placeholder="4000"
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.anthropic.sendReasoning"
|
||||
label="Send Reasoning"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.anthropic.disableParallelToolUse"
|
||||
label="Disable Parallel Tool Use"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const GoogleFields: FC<FieldRenderContext> = (props) => (
|
||||
<div className="space-y-2">
|
||||
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
|
||||
Google options
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.google.thinkingBudget"
|
||||
label="Thinking budget"
|
||||
placeholder="1024"
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.google.includeThoughts"
|
||||
label="Include thoughts"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.google.cachedContent"
|
||||
label="Cached content"
|
||||
placeholder="cached-contents/abc123"
|
||||
/>
|
||||
<JSONField
|
||||
{...props}
|
||||
fieldKey="config.google.safetySettingsJSON"
|
||||
label="Safety settings JSON"
|
||||
placeholder={`[
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.google.thinkingBudget"
|
||||
label="Thinking Budget"
|
||||
placeholder="1024"
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.google.includeThoughts"
|
||||
label="Include Thoughts"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.google.cachedContent"
|
||||
label="Cached Content"
|
||||
placeholder="cached-contents/abc123"
|
||||
/>
|
||||
<JSONField
|
||||
{...props}
|
||||
fieldKey="config.google.safetySettingsJSON"
|
||||
label="Safety Settings JSON"
|
||||
placeholder={`[
|
||||
{"category":"HARM_CATEGORY_DANGEROUS_CONTENT","threshold":"BLOCK_ONLY_HIGH"}
|
||||
]`}
|
||||
/>
|
||||
</div>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const OpenAICompatFields: FC<FieldRenderContext> = (props) => (
|
||||
<div className="space-y-2">
|
||||
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
|
||||
OpenAI-compatible options
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openaicompat.reasoningEffort"
|
||||
label="Reasoning effort"
|
||||
options={modelConfigReasoningEffortOptions}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openaicompat.user"
|
||||
label="User"
|
||||
placeholder="end-user-id"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openaicompat.reasoningEffort"
|
||||
label="Reasoning Effort"
|
||||
options={modelConfigReasoningEffortOptions}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openaicompat.user"
|
||||
label="User"
|
||||
placeholder="end-user-id"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const OpenRouterFields: FC<FieldRenderContext> = (props) => (
|
||||
<div className="space-y-2">
|
||||
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
|
||||
OpenRouter options
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.reasoningEnabled"
|
||||
label="Reasoning enabled"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.reasoningEffort"
|
||||
label="Reasoning effort"
|
||||
options={modelConfigReasoningEffortOptions}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.reasoningMaxTokens"
|
||||
label="Reasoning max tokens"
|
||||
placeholder="2048"
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.reasoningExclude"
|
||||
label="Reasoning exclude"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.parallelToolCalls"
|
||||
label="Parallel tool calls"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.includeUsage"
|
||||
label="Include usage"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.user"
|
||||
label="User"
|
||||
placeholder="end-user-id"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.reasoningEnabled"
|
||||
label="Reasoning Enabled"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.reasoningEffort"
|
||||
label="Reasoning Effort"
|
||||
options={modelConfigReasoningEffortOptions}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.reasoningMaxTokens"
|
||||
label="Reasoning Max Tokens"
|
||||
placeholder="2048"
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.reasoningExclude"
|
||||
label="Reasoning Exclude"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.parallelToolCalls"
|
||||
label="Parallel Tool Calls"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.includeUsage"
|
||||
label="Include Usage"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.openrouter.user"
|
||||
label="User"
|
||||
placeholder="end-user-id"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const VercelFields: FC<FieldRenderContext> = (props) => (
|
||||
<div className="space-y-2">
|
||||
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
|
||||
Vercel options
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.vercel.reasoningEnabled"
|
||||
label="Reasoning enabled"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.vercel.reasoningEffort"
|
||||
label="Reasoning effort"
|
||||
options={modelConfigReasoningEffortOptions}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.vercel.reasoningMaxTokens"
|
||||
label="Reasoning max tokens"
|
||||
placeholder="2048"
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.vercel.reasoningExclude"
|
||||
label="Reasoning exclude"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.vercel.parallelToolCalls"
|
||||
label="Parallel tool calls"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.vercel.user"
|
||||
label="User"
|
||||
placeholder="end-user-id"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.vercel.reasoningEnabled"
|
||||
label="Reasoning Enabled"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.vercel.reasoningEffort"
|
||||
label="Reasoning Effort"
|
||||
options={modelConfigReasoningEffortOptions}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.vercel.reasoningMaxTokens"
|
||||
label="Reasoning Max Tokens"
|
||||
placeholder="2048"
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.vercel.reasoningExclude"
|
||||
label="Reasoning Exclude"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<SelectField
|
||||
{...props}
|
||||
fieldKey="config.vercel.parallelToolCalls"
|
||||
label="Parallel Tool Calls"
|
||||
options={["true", "false"]}
|
||||
/>
|
||||
<InputField
|
||||
{...props}
|
||||
fieldKey="config.vercel.user"
|
||||
label="User"
|
||||
placeholder="end-user-id"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -436,101 +402,90 @@ type ModelConfigFieldsProps = {
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provider-specific fields (reasoning, tool calls, etc.) that
|
||||
* should be visible at the top level of the model form.
|
||||
*/
|
||||
export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
|
||||
provider,
|
||||
form,
|
||||
fieldErrors,
|
||||
disabled,
|
||||
}) => {
|
||||
const ctx: FieldRenderContext = {
|
||||
form,
|
||||
fieldErrors,
|
||||
disabled,
|
||||
};
|
||||
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
|
||||
const normalized = normalizeProvider(provider);
|
||||
|
||||
const renderProviderSpecificFields = () => {
|
||||
switch (normalized) {
|
||||
case "openai":
|
||||
return <OpenAIFields {...ctx} sectionTitle="OpenAI options" />;
|
||||
case "azure":
|
||||
return <OpenAIFields {...ctx} sectionTitle="OpenAI options (Azure)" />;
|
||||
case "anthropic":
|
||||
return <AnthropicFields {...ctx} sectionTitle="Anthropic options" />;
|
||||
case "bedrock":
|
||||
return (
|
||||
<AnthropicFields
|
||||
{...ctx}
|
||||
sectionTitle="Anthropic options (Bedrock)"
|
||||
/>
|
||||
);
|
||||
case "google":
|
||||
return <GoogleFields {...ctx} />;
|
||||
case "openaicompat":
|
||||
return <OpenAICompatFields {...ctx} />;
|
||||
case "openrouter":
|
||||
return <OpenRouterFields {...ctx} />;
|
||||
case "vercel":
|
||||
return <VercelFields {...ctx} />;
|
||||
default:
|
||||
return (
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
No provider-specific options are available for this provider.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
switch (normalized) {
|
||||
case "openai":
|
||||
return <OpenAIFields {...ctx} />;
|
||||
case "azure":
|
||||
return <OpenAIFields {...ctx} />;
|
||||
case "anthropic":
|
||||
return <AnthropicFields {...ctx} />;
|
||||
case "bedrock":
|
||||
return <AnthropicFields {...ctx} />;
|
||||
case "google":
|
||||
return <GoogleFields {...ctx} />;
|
||||
case "openaicompat":
|
||||
return <OpenAICompatFields {...ctx} />;
|
||||
case "openrouter":
|
||||
return <OpenRouterFields {...ctx} />;
|
||||
case "vercel":
|
||||
return <VercelFields {...ctx} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* General model config fields (max output tokens, temperature,
|
||||
* top P, etc.) intended to be shown under an "Advanced" section.
|
||||
*/
|
||||
export const GeneralModelConfigFields: FC<ModelConfigFieldsProps> = ({
|
||||
form,
|
||||
fieldErrors,
|
||||
disabled,
|
||||
}) => {
|
||||
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="m-0 text-[13px] font-medium text-content-primary">
|
||||
Model call config
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="m-0 text-xs font-medium uppercase tracking-wide text-content-secondary">
|
||||
General options
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.maxOutputTokens"
|
||||
label="Max output tokens"
|
||||
placeholder="32000"
|
||||
/>
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.temperature"
|
||||
label="Temperature"
|
||||
placeholder="0.2"
|
||||
/>
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.topP"
|
||||
label="Top P"
|
||||
placeholder="0.95"
|
||||
/>
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.topK"
|
||||
label="Top K"
|
||||
placeholder="40"
|
||||
/>
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.presencePenalty"
|
||||
label="Presence penalty"
|
||||
placeholder="0"
|
||||
/>
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.frequencyPenalty"
|
||||
label="Frequency penalty"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{renderProviderSpecificFields()}
|
||||
</div>
|
||||
<>
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.maxOutputTokens"
|
||||
label="Max Output Tokens"
|
||||
placeholder="32000"
|
||||
/>
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.temperature"
|
||||
label="Temperature"
|
||||
placeholder="0.2"
|
||||
/>
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.topP"
|
||||
label="Top P"
|
||||
placeholder="0.95"
|
||||
/>
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.topK"
|
||||
label="Top K"
|
||||
placeholder="40"
|
||||
/>
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.presencePenalty"
|
||||
label="Presence Penalty"
|
||||
placeholder="0"
|
||||
/>
|
||||
<InputField
|
||||
{...ctx}
|
||||
fieldKey="config.frequencyPenalty"
|
||||
label="Frequency Penalty"
|
||||
placeholder="0"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Checkbox } from "components/Checkbox/Checkbox";
|
||||
import { Input } from "components/Input/Input";
|
||||
import { Label } from "components/Label/Label";
|
||||
import {
|
||||
@@ -12,14 +10,22 @@ import {
|
||||
SelectValue,
|
||||
} from "components/Select/Select";
|
||||
import { useFormik } from "formik";
|
||||
import { ArrowLeftIcon, Loader2Icon, PlusIcon, SaveIcon } from "lucide-react";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
Loader2Icon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useMemo, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { getFormHelpers } from "utils/formUtils";
|
||||
|
||||
import * as Yup from "yup";
|
||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||
import { ModelConfigFields } from "./ModelConfigFields";
|
||||
import {
|
||||
GeneralModelConfigFields,
|
||||
ModelConfigFields,
|
||||
} from "./ModelConfigFields";
|
||||
import {
|
||||
buildInitialModelFormValues,
|
||||
buildModelConfigFromForm,
|
||||
@@ -27,45 +33,25 @@ import {
|
||||
parsePositiveInteger,
|
||||
parseThresholdInteger,
|
||||
} from "./modelConfigFormLogic";
|
||||
import { getModelConfigSchemaReference } from "./modelConfigSchemas";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────
|
||||
|
||||
const makeValidationSchema = (isEditing: boolean) =>
|
||||
Yup.object({
|
||||
model: Yup.string().trim().required("Model ID is required."),
|
||||
displayName: Yup.string(),
|
||||
contextLimit: isEditing
|
||||
? Yup.string()
|
||||
.trim()
|
||||
.required("Context limit is required.")
|
||||
.test(
|
||||
"positive-integer",
|
||||
"Context limit must be a positive integer.",
|
||||
(value) => !value || parsePositiveInteger(value) !== null,
|
||||
)
|
||||
: Yup.string().test(
|
||||
"positive-integer",
|
||||
"Context limit must be a positive integer.",
|
||||
(value) => !value?.trim() || parsePositiveInteger(value) !== null,
|
||||
),
|
||||
compressionThreshold: isEditing
|
||||
? Yup.string()
|
||||
.trim()
|
||||
.required("Compression threshold is required.")
|
||||
.test(
|
||||
"threshold-range",
|
||||
"Compression threshold must be a number between 0 and 100.",
|
||||
(value) => !value || parseThresholdInteger(value) !== null,
|
||||
)
|
||||
: Yup.string().test(
|
||||
"threshold-range",
|
||||
"Compression threshold must be a number between 0 and 100.",
|
||||
(value) => !value?.trim() || parseThresholdInteger(value) !== null,
|
||||
),
|
||||
isDefault: Yup.boolean(),
|
||||
});
|
||||
const validationSchema = Yup.object({
|
||||
model: Yup.string().trim().required("Model ID is required."),
|
||||
displayName: Yup.string(),
|
||||
contextLimit: Yup.string().test(
|
||||
"positive-integer",
|
||||
"Context limit must be a positive integer.",
|
||||
(value) => !value?.trim() || parsePositiveInteger(value) !== null,
|
||||
),
|
||||
compressionThreshold: Yup.string().test(
|
||||
"threshold-range",
|
||||
"Compression threshold must be a number between 0 and 100.",
|
||||
(value) => !value?.trim() || parseThresholdInteger(value) !== null,
|
||||
),
|
||||
isDefault: Yup.boolean(),
|
||||
});
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────
|
||||
|
||||
@@ -86,6 +72,7 @@ type ModelFormProps = {
|
||||
req: TypesGen.UpdateChatModelConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onCancel: () => void;
|
||||
onDeleteModel?: () => void;
|
||||
};
|
||||
|
||||
export const ModelForm: FC<ModelFormProps> = ({
|
||||
@@ -99,19 +86,16 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
onCreateModel,
|
||||
onUpdateModel,
|
||||
onCancel,
|
||||
onDeleteModel,
|
||||
}) => {
|
||||
const isEditing = Boolean(editingModel);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const canManageModels = Boolean(
|
||||
selectedProviderState?.providerConfig &&
|
||||
selectedProviderState.hasEffectiveAPIKey,
|
||||
);
|
||||
|
||||
const validationSchema = useMemo(
|
||||
() => makeValidationSchema(isEditing),
|
||||
[isEditing],
|
||||
);
|
||||
|
||||
const form = useFormik<ModelFormValues>({
|
||||
initialValues: buildInitialModelFormValues(editingModel),
|
||||
validationSchema,
|
||||
@@ -137,65 +121,59 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
const trimmedDisplayName = values.displayName.trim();
|
||||
const builtModelConfig = buildResult.modelConfig;
|
||||
|
||||
try {
|
||||
if (isEditing && editingModel) {
|
||||
const req: TypesGen.UpdateChatModelConfigRequest = {
|
||||
...(trimmedModel !== editingModel.model && {
|
||||
model: trimmedModel,
|
||||
}),
|
||||
...(trimmedDisplayName !== (editingModel.display_name ?? "") && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
...(parsedContextLimit !== null &&
|
||||
parsedContextLimit !== editingModel.context_limit && {
|
||||
context_limit: parsedContextLimit,
|
||||
}),
|
||||
...(parsedCompressionThreshold !== null &&
|
||||
parsedCompressionThreshold !==
|
||||
editingModel.compression_threshold && {
|
||||
compression_threshold: parsedCompressionThreshold,
|
||||
}),
|
||||
...(values.isDefault !== editingModel.is_default && {
|
||||
is_default: values.isDefault,
|
||||
}),
|
||||
// Always send model_config so it can be cleared or updated.
|
||||
model_config: builtModelConfig,
|
||||
};
|
||||
|
||||
await onUpdateModel(editingModel.id, req);
|
||||
} else {
|
||||
if (!selectedProviderState?.providerConfig) return;
|
||||
|
||||
const req: TypesGen.CreateChatModelConfigRequest = {
|
||||
provider: selectedProviderState.provider,
|
||||
if (isEditing && editingModel) {
|
||||
const req: TypesGen.UpdateChatModelConfigRequest = {
|
||||
...(trimmedModel !== editingModel.model && {
|
||||
model: trimmedModel,
|
||||
...(parsedContextLimit !== null && {
|
||||
}),
|
||||
...(trimmedDisplayName !== (editingModel.display_name ?? "") && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
...(parsedContextLimit !== null &&
|
||||
parsedContextLimit !== editingModel.context_limit && {
|
||||
context_limit: parsedContextLimit,
|
||||
}),
|
||||
...(parsedCompressionThreshold !== null && {
|
||||
...(parsedCompressionThreshold !== null &&
|
||||
parsedCompressionThreshold !==
|
||||
editingModel.compression_threshold && {
|
||||
compression_threshold: parsedCompressionThreshold,
|
||||
}),
|
||||
...(trimmedDisplayName && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
...(values.isDefault && {
|
||||
is_default: true,
|
||||
}),
|
||||
...(builtModelConfig && {
|
||||
model_config: builtModelConfig,
|
||||
}),
|
||||
};
|
||||
...(values.isDefault !== editingModel.is_default && {
|
||||
is_default: values.isDefault,
|
||||
}),
|
||||
// Always send model_config so it can be cleared or updated.
|
||||
model_config: builtModelConfig,
|
||||
};
|
||||
|
||||
await onCreateModel(req);
|
||||
}
|
||||
// Navigation is handled by the parent (ModelsSection) after
|
||||
// the mutation promise resolves, so we do not call onCancel()
|
||||
// here to avoid a double view-transition.
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(error, "Failed to save model configuration."),
|
||||
);
|
||||
await onUpdateModel(editingModel.id, req);
|
||||
} else {
|
||||
if (!selectedProviderState?.providerConfig) return;
|
||||
|
||||
const req: TypesGen.CreateChatModelConfigRequest = {
|
||||
provider: selectedProviderState.provider,
|
||||
model: trimmedModel,
|
||||
...(parsedContextLimit !== null && {
|
||||
context_limit: parsedContextLimit,
|
||||
}),
|
||||
...(parsedCompressionThreshold !== null && {
|
||||
compression_threshold: parsedCompressionThreshold,
|
||||
}),
|
||||
...(trimmedDisplayName && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
...(values.isDefault && {
|
||||
is_default: true,
|
||||
}),
|
||||
...(builtModelConfig && {
|
||||
model_config: builtModelConfig,
|
||||
}),
|
||||
};
|
||||
|
||||
await onCreateModel(req);
|
||||
}
|
||||
// Navigation is handled by the parent (ModelsSection) after
|
||||
// the mutation promise resolves, so we do not call onCancel()
|
||||
// here to avoid a double view-transition.
|
||||
},
|
||||
});
|
||||
|
||||
@@ -213,11 +191,6 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
const hasFieldErrors =
|
||||
Object.keys(modelConfigFormBuildResult.fieldErrors).length > 0;
|
||||
|
||||
const modelConfigSchemaReference = useMemo(
|
||||
() => getModelConfigSchemaReference(selectedProviderState),
|
||||
[selectedProviderState],
|
||||
);
|
||||
|
||||
// ── Provider select (shared across all form states) ───────
|
||||
|
||||
const providerSelect = (
|
||||
@@ -243,11 +216,7 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
{providerStates.map((ps) => (
|
||||
<SelectItem key={ps.provider} value={ps.provider}>
|
||||
<span className="flex items-center gap-2">
|
||||
<ProviderIcon
|
||||
provider={ps.provider}
|
||||
className="h-4 w-4"
|
||||
active={ps.hasEffectiveAPIKey}
|
||||
/>
|
||||
<ProviderIcon provider={ps.provider} className="h-4 w-4" />
|
||||
{ps.label}
|
||||
</span>
|
||||
</SelectItem>
|
||||
@@ -260,22 +229,33 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
// No provider selected or configs unavailable.
|
||||
if (!selectedProviderState || modelConfigsUnavailable) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border px-6 py-4">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Back</span>
|
||||
</Button>
|
||||
<h3 className="m-0 text-base font-semibold text-content-primary">
|
||||
{isEditing ? "Edit model" : "Add model"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-3 p-6">{providerSelect}</div>
|
||||
<div>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onCancel();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
onCancel();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 text-sm text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</div>{" "}
|
||||
<h2 className="m-0 text-lg font-medium text-content-primary">
|
||||
{isEditing ? "Edit Model" : "Add Model"}
|
||||
</h2>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
<div className="space-y-3">{providerSelect}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -283,24 +263,35 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
// Provider can't manage models.
|
||||
if (!canManageModels && !isEditing) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border px-6 py-4">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Back</span>
|
||||
</Button>
|
||||
<h3 className="m-0 text-base font-semibold text-content-primary">
|
||||
Add model
|
||||
</h3>
|
||||
<div>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onCancel();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
onCancel();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 text-sm text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</div>
|
||||
<div className="space-y-3 p-6">
|
||||
<h2 className="m-0 text-lg font-medium text-content-primary">
|
||||
Add Model
|
||||
</h2>{" "}
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
<div className="space-y-3">
|
||||
{providerSelect}
|
||||
<p className="text-[13px] text-content-secondary">
|
||||
<p className="text-sm text-content-secondary">
|
||||
{!selectedProviderState.providerConfig
|
||||
? "Create a managed provider config on the Providers tab before adding models."
|
||||
: "Set an API key for this provider on the Providers tab before adding models."}
|
||||
@@ -313,238 +304,132 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
// ── Full form ─────────────────────────────────────────────
|
||||
|
||||
const modelField = getFieldHelpers("model");
|
||||
const displayNameField = getFieldHelpers("displayName");
|
||||
const contextLimitField = getFieldHelpers("contextLimit");
|
||||
const compressionThresholdField = getFieldHelpers("compressionThreshold");
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header bar with back button */}
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Back</span>
|
||||
</Button>
|
||||
<h3 className="m-0 text-base font-semibold text-content-primary">
|
||||
{isEditing ? "Edit model" : "Add model"}
|
||||
</h3>
|
||||
{selectedProviderState && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-surface-secondary/40 px-2 py-0.5 text-xs text-content-secondary">
|
||||
<ProviderIcon
|
||||
provider={selectedProviderState.provider}
|
||||
className="h-3.5 w-3.5"
|
||||
active
|
||||
/>
|
||||
{selectedProviderState.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-h-full flex-col">
|
||||
{/* Back */}
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={onCancel}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onCancel();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
onCancel();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 text-sm text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</div>
|
||||
{/* Header — editable display name */}
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedProviderState && (
|
||||
<ProviderIcon
|
||||
provider={selectedProviderState.provider}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
{...form.getFieldProps("displayName")}
|
||||
disabled={isSaving}
|
||||
className="m-0 w-full border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0"
|
||||
placeholder={
|
||||
isEditing ? (editingModel?.model ?? "Model name") : "Model name"
|
||||
}
|
||||
/>
|
||||
</div>{" "}
|
||||
</div>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
|
||||
{/* Form body */}
|
||||
<form
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={form.handleSubmit}
|
||||
>
|
||||
<div className="flex-1 space-y-5 overflow-y-auto p-6">
|
||||
{/* Model identity */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="m-0 text-[13px] font-medium text-content-primary">
|
||||
Model identity
|
||||
</p>
|
||||
<form className="flex flex-1 flex-col" onSubmit={form.handleSubmit}>
|
||||
<div className="space-y-5">
|
||||
{/* Model ID + Context Limit */}
|
||||
<div className="grid items-start gap-5 sm:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={modelField.id}
|
||||
className="text-sm font-medium text-content-primary"
|
||||
>
|
||||
Model Identifier{" "}
|
||||
<span className="text-xs font-bold text-content-destructive">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Select provider and model naming details.
|
||||
The model identifier sent to the provider API.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid items-start gap-3 md:grid-cols-3">
|
||||
{providerSelect}
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={modelField.id}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
Model ID{" "}
|
||||
<span className="text-xs text-content-destructive font-bold">
|
||||
*
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={modelField.id}
|
||||
name={modelField.name}
|
||||
className={cn(
|
||||
"h-10 text-[13px] placeholder:text-content-disabled",
|
||||
modelField.error && "border-content-destructive",
|
||||
)}
|
||||
placeholder="gpt-5, claude-sonnet-4-5, etc."
|
||||
value={modelField.value}
|
||||
onChange={modelField.onChange}
|
||||
onBlur={modelField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={modelField.error}
|
||||
aria-describedby={
|
||||
modelField.error ? `${modelField.id}-error` : undefined
|
||||
}
|
||||
/>
|
||||
{modelField.error && (
|
||||
<p
|
||||
id={`${modelField.id}-error`}
|
||||
className="m-0 text-xs text-content-destructive"
|
||||
>
|
||||
{modelField.helperText}
|
||||
</p>
|
||||
<Input
|
||||
id={modelField.id}
|
||||
name={modelField.name}
|
||||
className={cn(
|
||||
"h-9 text-[13px] placeholder:text-content-disabled",
|
||||
modelField.error && "border-content-destructive",
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={displayNameField.id}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
Display name
|
||||
</Label>
|
||||
<Input
|
||||
id={displayNameField.id}
|
||||
name={displayNameField.name}
|
||||
className="h-10 text-[13px] placeholder:text-content-disabled"
|
||||
placeholder="Friendly label"
|
||||
value={displayNameField.value}
|
||||
onChange={displayNameField.onChange}
|
||||
onBlur={displayNameField.onBlur}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime limits */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="m-0 text-[13px] font-medium text-content-primary">
|
||||
Runtime limits
|
||||
</p>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
{isEditing
|
||||
? "These values are required for existing models."
|
||||
: "Leave values blank to use backend defaults."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={contextLimitField.id}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
Context limit{" "}
|
||||
{isEditing && (
|
||||
<span className="text-xs text-content-destructive font-bold">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id={contextLimitField.id}
|
||||
name={contextLimitField.name}
|
||||
className={cn(
|
||||
"h-10 text-[13px] placeholder:text-content-disabled",
|
||||
contextLimitField.error && "border-content-destructive",
|
||||
)}
|
||||
placeholder="200000"
|
||||
value={contextLimitField.value}
|
||||
onChange={contextLimitField.onChange}
|
||||
onBlur={contextLimitField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={contextLimitField.error}
|
||||
aria-describedby={
|
||||
contextLimitField.error
|
||||
? `${contextLimitField.id}-error`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{contextLimitField.error && (
|
||||
<p
|
||||
id={`${contextLimitField.id}-error`}
|
||||
className="m-0 text-xs text-content-destructive"
|
||||
>
|
||||
{contextLimitField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={compressionThresholdField.id}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
Compression threshold{" "}
|
||||
{isEditing && (
|
||||
<span className="text-xs text-content-destructive font-bold">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id={compressionThresholdField.id}
|
||||
name={compressionThresholdField.name}
|
||||
className={cn(
|
||||
"h-10 text-[13px] placeholder:text-content-disabled",
|
||||
compressionThresholdField.error &&
|
||||
"border-content-destructive",
|
||||
)}
|
||||
placeholder="70"
|
||||
value={compressionThresholdField.value}
|
||||
onChange={compressionThresholdField.onChange}
|
||||
onBlur={compressionThresholdField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={compressionThresholdField.error}
|
||||
aria-describedby={
|
||||
compressionThresholdField.error
|
||||
? `${compressionThresholdField.id}-error`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{compressionThresholdField.error && (
|
||||
<p
|
||||
id={`${compressionThresholdField.id}-error`}
|
||||
className="m-0 text-xs text-content-destructive"
|
||||
>
|
||||
{compressionThresholdField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="m-0 text-[13px] font-medium text-content-primary">
|
||||
Default behavior
|
||||
</p>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Only one model can be the default for new prompts.
|
||||
</p>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="isDefault"
|
||||
className="flex items-start gap-2 text-[13px] text-content-primary"
|
||||
>
|
||||
<Checkbox
|
||||
id="isDefault"
|
||||
checked={form.values.isDefault}
|
||||
onCheckedChange={(checked) =>
|
||||
void form.setFieldValue("isDefault", checked === true)
|
||||
}
|
||||
placeholder="e.g. gpt-5, claude-sonnet-4-5"
|
||||
value={modelField.value}
|
||||
onChange={modelField.onChange}
|
||||
onBlur={modelField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={modelField.error}
|
||||
aria-describedby={
|
||||
modelField.error ? `${modelField.id}-error` : undefined
|
||||
}
|
||||
/>
|
||||
<span>Use this as the default model for new prompts.</span>
|
||||
</label>
|
||||
{modelField.error && (
|
||||
<p
|
||||
id={`${modelField.id}-error`}
|
||||
className="m-0 text-xs text-content-destructive"
|
||||
>
|
||||
{modelField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={contextLimitField.id}
|
||||
className="text-sm font-medium text-content-primary"
|
||||
>
|
||||
Context Limit
|
||||
</Label>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Max tokens in the context window.
|
||||
</p>
|
||||
<Input
|
||||
id={contextLimitField.id}
|
||||
name={contextLimitField.name}
|
||||
className={cn(
|
||||
"h-9 text-[13px] placeholder:text-content-disabled",
|
||||
contextLimitField.error && "border-content-destructive",
|
||||
)}
|
||||
placeholder="200000"
|
||||
value={contextLimitField.value}
|
||||
onChange={contextLimitField.onChange}
|
||||
onBlur={contextLimitField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={contextLimitField.error}
|
||||
/>
|
||||
{contextLimitField.error && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{contextLimitField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model call config fields */}
|
||||
{/* Provider-specific model config fields */}
|
||||
<ModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
@@ -552,51 +437,113 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
{/* Schema reference */}
|
||||
<details className="group rounded-xl border border-border-default/80 bg-surface-secondary/20 shadow-sm">
|
||||
<summary className="cursor-pointer select-none px-4 py-3 text-[13px] font-medium text-content-secondary hover:text-content-primary">
|
||||
Model config schema reference (
|
||||
{modelConfigSchemaReference.providerLabel})
|
||||
</summary>
|
||||
<div className="space-y-2 border-t border-border/60 px-4 pb-4 pt-3">
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Reference JSON for <code>create/update chat model config</code>{" "}
|
||||
payloads.
|
||||
</p>
|
||||
{modelConfigSchemaReference.notes.map((note) => (
|
||||
<p key={note} className="m-0 text-xs text-content-secondary">
|
||||
{note}
|
||||
</p>
|
||||
))}
|
||||
<pre
|
||||
data-testid="chat-model-config-schema"
|
||||
className="max-h-60 overflow-auto rounded-md border border-border-default/80 bg-surface-primary/80 p-2 font-mono text-[11px] leading-relaxed text-content-secondary"
|
||||
>
|
||||
{modelConfigSchemaReference.schemaJSON}
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
{/* Advanced — toggle */}
|
||||
<div>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setShowAdvanced((v) => !v);
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
setShowAdvanced((v) => !v);
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="inline-flex cursor-pointer items-center gap-1 text-sm font-medium text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
)}
|
||||
Advanced
|
||||
</div>{" "}
|
||||
{showAdvanced && (
|
||||
<div className="mt-4 space-y-5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<GeneralModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={compressionThresholdField.id}
|
||||
className="text-sm font-medium text-content-primary"
|
||||
>
|
||||
Compression Threshold
|
||||
</Label>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Percentage at which context is compressed.
|
||||
</p>
|
||||
<Input
|
||||
id={compressionThresholdField.id}
|
||||
name={compressionThresholdField.name}
|
||||
className={cn(
|
||||
"h-9 text-[13px] placeholder:text-content-disabled",
|
||||
compressionThresholdField.error &&
|
||||
"border-content-destructive",
|
||||
)}
|
||||
placeholder="70"
|
||||
value={compressionThresholdField.value}
|
||||
onChange={compressionThresholdField.onChange}
|
||||
onBlur={compressionThresholdField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={compressionThresholdField.error}
|
||||
/>
|
||||
{compressionThresholdField.error && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{compressionThresholdField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky footer actions */}
|
||||
<div className="flex items-center justify-end gap-2 border-t border-border bg-surface-primary px-6 py-4">
|
||||
<Button size="sm" variant="outline" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isSaving || !form.isValid || hasFieldErrors}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
) : isEditing ? (
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
{/* Footer — pushed to bottom */}
|
||||
<div className="mt-auto pt-6">
|
||||
<hr className="mb-4 border-0 border-t border-solid border-border" />
|
||||
<div className="flex items-center justify-between">
|
||||
{isEditing && editingModel && onDeleteModel ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
|
||||
disabled={isSaving}
|
||||
onClick={() => onDeleteModel()}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
)}
|
||||
{isEditing ? "Save changes" : "Add model"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}{" "}
|
||||
<Button
|
||||
size="lg"
|
||||
type="submit"
|
||||
disabled={isSaving || !form.isValid || hasFieldErrors}
|
||||
>
|
||||
{isSaving && <Loader2Icon className="h-4 w-4 animate-spin" />}
|
||||
{isEditing ? "Save" : "Add model"}{" "}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,20 +2,37 @@ import type * as TypesGen from "api/typesGenerated";
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
||||
import { PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { formatProviderLabel } from "../modelOptions";
|
||||
import { SectionHeader } from "../SectionHeader";
|
||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||
import { ModelForm } from "./ModelForm";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
type ModelView =
|
||||
| { mode: "list" }
|
||||
| { mode: "add" }
|
||||
| { mode: "add"; provider: string }
|
||||
| { mode: "edit"; model: TypesGen.ChatModelConfig };
|
||||
|
||||
type ModelsSectionProps = {
|
||||
sectionLabel?: string;
|
||||
providerStates: readonly ProviderState[];
|
||||
selectedProvider: string | null;
|
||||
selectedProviderState: ProviderState | null;
|
||||
@@ -36,6 +53,7 @@ type ModelsSectionProps = {
|
||||
};
|
||||
|
||||
export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
sectionLabel,
|
||||
providerStates,
|
||||
selectedProvider,
|
||||
selectedProviderState,
|
||||
@@ -57,14 +75,19 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
if (view.mode === "add" || view.mode === "edit") {
|
||||
const editingModel = view.mode === "edit" ? view.model : undefined;
|
||||
|
||||
// When editing, select the model's provider so the form shows
|
||||
// the correct provider-specific fields.
|
||||
const effectiveProvider = editingModel
|
||||
? editingModel.provider
|
||||
: selectedProvider;
|
||||
const effectiveProviderState = editingModel
|
||||
? (providerStates.find((ps) => ps.provider === editingModel.provider) ??
|
||||
null)
|
||||
const getEffectiveProvider = () => {
|
||||
if (editingModel) {
|
||||
return editingModel.provider;
|
||||
}
|
||||
if (view.mode === "add") {
|
||||
return view.provider;
|
||||
}
|
||||
return selectedProvider;
|
||||
};
|
||||
|
||||
const effectiveProvider = getEffectiveProvider();
|
||||
const effectiveProviderState = effectiveProvider
|
||||
? (providerStates.find((ps) => ps.provider === effectiveProvider) ?? null)
|
||||
: selectedProviderState;
|
||||
|
||||
return (
|
||||
@@ -86,130 +109,179 @@ export const ModelsSection: FC<ModelsSectionProps> = ({
|
||||
setView({ mode: "list" });
|
||||
}}
|
||||
onCancel={() => setView({ mode: "list" })}
|
||||
onDeleteModel={
|
||||
editingModel
|
||||
? () => {
|
||||
setModelToDelete(editingModel);
|
||||
setView({ mode: "list" });
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── List view ──────────────────────────────────────────────
|
||||
|
||||
// Only show providers that have an API key configured.
|
||||
const addableProviders = providerStates.filter(
|
||||
(ps) => ps.providerConfig && ps.hasEffectiveAPIKey,
|
||||
);
|
||||
|
||||
const addButton = addableProviders.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" className="gap-1.5" aria-label="Add model">
|
||||
{" "}
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add
|
||||
<ChevronDownIcon className="h-3.5 w-3.5 text-content-secondary" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{addableProviders.map((ps) => (
|
||||
<DropdownMenuItem
|
||||
key={ps.provider}
|
||||
onClick={() => {
|
||||
onSelectedProviderChange(ps.provider);
|
||||
setView({ mode: "add", provider: ps.provider });
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<ProviderIcon provider={ps.provider} className="h-5 w-5" />
|
||||
{ps.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
const handleSetDefault = (modelConfig: TypesGen.ChatModelConfig) => {
|
||||
if (modelConfig.is_default) return;
|
||||
void onUpdateModel(modelConfig.id, { is_default: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
{/* Add model button */}
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setView({ mode: "add" })}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
Add model
|
||||
</Button>
|
||||
</div>
|
||||
{sectionLabel && (
|
||||
<SectionHeader
|
||||
label={sectionLabel}
|
||||
description="Manage models available to Agents."
|
||||
action={addButton || undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Model list */}
|
||||
{modelConfigs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border bg-surface-secondary/20 px-6 py-12 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-surface-tertiary/50">
|
||||
<PlusIcon className="h-5 w-5 text-content-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="m-0 text-[13px] font-medium text-content-primary">
|
||||
No models configured
|
||||
</p>
|
||||
<p className="m-0 mt-1 text-xs text-content-secondary">
|
||||
Add a model to get started with Agents.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-1 gap-1.5"
|
||||
onClick={() => setView({ mode: "add" })}
|
||||
{modelConfigs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
||||
<p className="m-0 text-sm text-content-secondary">
|
||||
No models configured yet.
|
||||
</p>
|
||||
{addableProviders.length > 0 && addButton}
|
||||
{addableProviders.length === 0 && (
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Connect a provider first to add models.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{modelConfigs.map((modelConfig) => (
|
||||
<div
|
||||
key={modelConfig.id}
|
||||
className="flex items-center gap-3.5 px-3 py-3"
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5" />
|
||||
Add your first model
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||||
{modelConfigs.map((modelConfig) => (
|
||||
{" "}
|
||||
{/* Star for default */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSetDefault(modelConfig);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.stopPropagation();
|
||||
handleSetDefault(modelConfig);
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
e.stopPropagation();
|
||||
handleSetDefault(modelConfig);
|
||||
}
|
||||
}}
|
||||
aria-disabled={isUpdating || modelConfig.is_default}
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors",
|
||||
modelConfig.is_default
|
||||
? "text-yellow-400"
|
||||
: "cursor-pointer text-content-secondary/30 hover:text-content-secondary",
|
||||
)}
|
||||
>
|
||||
<StarIcon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
modelConfig.is_default && "fill-current",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{modelConfig.is_default
|
||||
? "Default model for new chats"
|
||||
: "Set as default for new chats"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Clickable row content */}
|
||||
<div
|
||||
key={modelConfig.id}
|
||||
className="group flex items-center gap-4 bg-surface-primary px-5 py-3.5 transition-colors hover:bg-surface-secondary/30"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={() => setView({ mode: "edit", model: modelConfig })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setView({ mode: "edit", model: modelConfig });
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
setView({ mode: "edit", model: modelConfig });
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 transition-colors hover:opacity-80"
|
||||
>
|
||||
{" "}
|
||||
<ProviderIcon
|
||||
provider={modelConfig.provider}
|
||||
className="h-8 w-8 shrink-0"
|
||||
active={modelConfig.enabled !== false}
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-[13px] font-semibold",
|
||||
modelConfig.enabled === false
|
||||
? "text-content-secondary"
|
||||
: "text-content-primary",
|
||||
)}
|
||||
>
|
||||
{modelConfig.display_name || modelConfig.model}
|
||||
</span>
|
||||
{modelConfig.is_default && (
|
||||
<Badge size="sm" variant="info">
|
||||
default
|
||||
</Badge>
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-[15px] font-medium",
|
||||
modelConfig.enabled === false
|
||||
? "text-content-secondary"
|
||||
: "text-content-primary",
|
||||
)}
|
||||
{modelConfig.enabled === false && (
|
||||
<Badge size="sm" variant="warning">
|
||||
disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-content-secondary">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{formatProviderLabel(modelConfig.provider)}
|
||||
</span>
|
||||
<span className="font-mono">{modelConfig.model}</span>
|
||||
<span>
|
||||
{modelConfig.context_limit.toLocaleString()} ctx
|
||||
</span>
|
||||
<span>{modelConfig.compression_threshold}% compress</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
className="h-8 w-8 text-content-secondary hover:text-content-primary"
|
||||
onClick={() =>
|
||||
setView({
|
||||
mode: "edit",
|
||||
model: modelConfig,
|
||||
})
|
||||
}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Edit model</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
className="h-8 w-8 text-content-secondary hover:text-content-destructive"
|
||||
onClick={() => setModelToDelete(modelConfig)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Delete model</span>
|
||||
</Button>
|
||||
{modelConfig.display_name || modelConfig.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{modelConfig.enabled === false && (
|
||||
<Badge size="xs" variant="warning">
|
||||
disabled
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronRightIcon className="h-5 w-5 shrink-0 text-content-secondary" />
|
||||
</div>{" "}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
isOpen={modelToDelete !== null}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Alert, AlertDetail, AlertTitle } from "components/Alert/Alert";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { CollapsibleContent } from "components/Collapsible/Collapsible";
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
||||
import { Input } from "components/Input/Input";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { type FC, type FormEvent, useEffect, useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { ChevronLeftIcon, InfoIcon, Loader2Icon } from "lucide-react";
|
||||
import { type FC, type FormEvent, useId, useState } from "react";
|
||||
import { formatProviderLabel } from "../modelOptions";
|
||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||
import { readOptionalString } from "./helpers";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
// Sentinel value used to represent an existing API key that the
|
||||
// backend won't reveal. If the user hasn't touched the field,
|
||||
// we know nothing changed.
|
||||
const API_KEY_PLACEHOLDER = "••••••••••••••••";
|
||||
|
||||
type ProviderFormProps = {
|
||||
provider: string;
|
||||
providerConfig: TypesGen.ChatProviderConfig | undefined;
|
||||
baseURL: string;
|
||||
isEnvPreset: boolean;
|
||||
providerState: ProviderState;
|
||||
providerConfigsUnavailable: boolean;
|
||||
isProviderMutationPending: boolean;
|
||||
onCreateProvider: (
|
||||
@@ -23,39 +31,58 @@ type ProviderFormProps = {
|
||||
providerConfigId: string,
|
||||
req: TypesGen.UpdateChatProviderConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteProvider: (providerConfigId: string) => Promise<void>;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
provider,
|
||||
providerConfig,
|
||||
baseURL,
|
||||
isEnvPreset,
|
||||
providerState,
|
||||
providerConfigsUnavailable,
|
||||
isProviderMutationPending,
|
||||
onCreateProvider,
|
||||
onUpdateProvider,
|
||||
onDeleteProvider,
|
||||
onBack,
|
||||
}) => {
|
||||
const displayNameInputId = useId();
|
||||
const { provider, providerConfig, baseURL, isEnvPreset } = providerState;
|
||||
|
||||
const apiKeyInputId = useId();
|
||||
const baseURLInputId = useId();
|
||||
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [baseURLValue, setBaseURLValue] = useState("");
|
||||
// Initial values are snapshotted when the provider config changes
|
||||
// so we can detect dirty state.
|
||||
const [initialValues] = useState(() => ({
|
||||
displayName: readOptionalString(providerConfig?.display_name) ?? "",
|
||||
baseURL: baseURL,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(readOptionalString(providerConfig?.display_name) ?? "");
|
||||
setApiKey("");
|
||||
setBaseURLValue(baseURL);
|
||||
}, [providerConfig, baseURL]);
|
||||
const [displayName, setDisplayName] = useState(initialValues.displayName);
|
||||
const [apiKey, setApiKey] = useState(
|
||||
providerState.hasManagedAPIKey ? API_KEY_PLACEHOLDER : "",
|
||||
);
|
||||
const [apiKeyTouched, setApiKeyTouched] = useState(false);
|
||||
const [baseURLValue, setBaseURLValue] = useState(initialValues.baseURL);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const isAPIKeyEnvManaged = isEnvPreset && !providerConfig;
|
||||
const requiresAPIKey = !providerConfig && !isAPIKeyEnvManaged;
|
||||
|
||||
// The actual API key value to submit — ignore the placeholder.
|
||||
const effectiveApiKey =
|
||||
apiKeyTouched && apiKey !== API_KEY_PLACEHOLDER ? apiKey.trim() : "";
|
||||
|
||||
// Dirty detection: has anything changed from the initial state?
|
||||
const isDirty =
|
||||
displayName.trim() !== initialValues.displayName ||
|
||||
effectiveApiKey !== "" ||
|
||||
baseURLValue.trim() !== initialValues.baseURL.trim();
|
||||
|
||||
const canSave =
|
||||
!providerConfigsUnavailable &&
|
||||
!isProviderMutationPending &&
|
||||
!isAPIKeyEnvManaged &&
|
||||
(!requiresAPIKey || apiKey.trim());
|
||||
isDirty &&
|
||||
(!requiresAPIKey || effectiveApiKey);
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -68,7 +95,6 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
}
|
||||
|
||||
const trimmedDisplayName = displayName.trim();
|
||||
const trimmedAPIKey = apiKey.trim();
|
||||
const trimmedBaseURL = baseURLValue.trim();
|
||||
|
||||
try {
|
||||
@@ -80,7 +106,7 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
...(trimmedDisplayName !== currentDisplayName && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
...(trimmedAPIKey && { api_key: trimmedAPIKey }),
|
||||
...(effectiveApiKey && { api_key: effectiveApiKey }),
|
||||
...(trimmedBaseURL !== currentBaseURL && {
|
||||
base_url: trimmedBaseURL,
|
||||
}),
|
||||
@@ -92,13 +118,13 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
|
||||
await onUpdateProvider(providerConfig.id, req);
|
||||
} else {
|
||||
if (!trimmedAPIKey) {
|
||||
if (!effectiveApiKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const req: TypesGen.CreateChatProviderConfigRequest = {
|
||||
provider,
|
||||
api_key: trimmedAPIKey,
|
||||
api_key: effectiveApiKey,
|
||||
...(trimmedDisplayName && {
|
||||
display_name: trimmedDisplayName,
|
||||
}),
|
||||
@@ -108,119 +134,213 @@ export const ProviderForm: FC<ProviderFormProps> = ({
|
||||
await onCreateProvider(req);
|
||||
}
|
||||
|
||||
// Only clear the API key field on success.
|
||||
setApiKey("");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
getErrorMessage(error, "Failed to save provider configuration."),
|
||||
);
|
||||
setApiKeyTouched(false);
|
||||
} catch {
|
||||
// Error is surfaced via the mutation's error state
|
||||
// in ChatModelAdminPanel, no toast needed.
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyFocus = () => {
|
||||
// Clear the placeholder on first focus so the user starts
|
||||
// with a blank field and Chrome doesn't try to autofill.
|
||||
if (!apiKeyTouched && apiKey === API_KEY_PLACEHOLDER) {
|
||||
setApiKey("");
|
||||
setApiKeyTouched(true);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = providerConfigsUnavailable || isProviderMutationPending;
|
||||
|
||||
return (
|
||||
<CollapsibleContent className="border-t border-border px-5 py-4">
|
||||
<div className="space-y-3">
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
{providerConfig
|
||||
? "Update this managed provider config for your deployment."
|
||||
: isAPIKeyEnvManaged
|
||||
? "This provider API key is managed by an environment variable."
|
||||
: "Create a managed provider config before enabling models."}
|
||||
</p>
|
||||
<div className="flex min-h-full flex-col">
|
||||
{/* Back */}
|
||||
<div
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={onBack}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onBack();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
onBack();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="mb-4 inline-flex cursor-pointer items-center gap-0.5 text-sm text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
Back
|
||||
</div>
|
||||
{/* Provider header — editable name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderIcon provider={provider} className="h-8 w-8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={displayName || formatProviderLabel(provider)}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
disabled={isDisabled || isAPIKeyEnvManaged}
|
||||
className="m-0 w-full border-0 bg-transparent p-0 text-lg font-medium text-content-primary outline-none placeholder:text-content-secondary focus:ring-0"
|
||||
placeholder={formatProviderLabel(provider)}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="h-4 w-4 shrink-0 cursor-help text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Uses the {formatProviderLabel(provider)} API specification
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
|
||||
{isAPIKeyEnvManaged && (
|
||||
<Alert severity="info">
|
||||
<AlertTitle>API key managed by environment variable.</AlertTitle>
|
||||
<AlertDetail>
|
||||
This provider key is configured from deployment environment
|
||||
settings and cannot be edited in this UI.
|
||||
</AlertDetail>
|
||||
</Alert>
|
||||
)}
|
||||
{isAPIKeyEnvManaged ? (
|
||||
<Alert severity="info">
|
||||
<AlertTitle>API key managed by environment variable</AlertTitle>
|
||||
<AlertDetail>
|
||||
This provider key is configured from deployment environment settings
|
||||
and cannot be edited in this UI.
|
||||
</AlertDetail>
|
||||
</Alert>
|
||||
) : (
|
||||
<form
|
||||
className="flex flex-1 flex-col"
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
autoComplete="off"
|
||||
data-form-type="other"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<ProviderField
|
||||
label="API Key"
|
||||
htmlFor={apiKeyInputId}
|
||||
required={!providerConfig}
|
||||
description="Secret key used to authenticate requests to this provider."
|
||||
>
|
||||
<Input
|
||||
id={apiKeyInputId}
|
||||
name="provider_api_token"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-form-type="other"
|
||||
data-bwignore
|
||||
style={{ WebkitTextSecurity: "disc" } as React.CSSProperties}
|
||||
className="h-9 font-mono text-[13px]"
|
||||
placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onFocus={handleApiKeyFocus}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
setApiKeyTouched(true);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</ProviderField>
|
||||
|
||||
{!isAPIKeyEnvManaged && (
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => void handleSubmit(event)}
|
||||
>
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="grid gap-1.5">
|
||||
<label
|
||||
htmlFor={displayNameInputId}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
<ProviderField
|
||||
label="Base URL"
|
||||
htmlFor={baseURLInputId}
|
||||
description="Custom endpoint for this provider. Leave empty to use the default."
|
||||
>
|
||||
<Input
|
||||
id={baseURLInputId}
|
||||
name="provider_base_url"
|
||||
className="h-9 text-[13px]"
|
||||
placeholder="https://api.example.com/v1"
|
||||
autoComplete="off"
|
||||
value={baseURLValue}
|
||||
onChange={(e) => setBaseURLValue(e.target.value)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</ProviderField>
|
||||
</div>
|
||||
|
||||
{/* Footer — pushed to bottom */}
|
||||
<div className="mt-auto pt-6">
|
||||
<hr className="mb-4 border-0 border-t border-solid border-border" />
|
||||
<div className="flex items-center justify-between">
|
||||
{providerConfig ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
type="button"
|
||||
className="text-content-secondary hover:text-content-destructive hover:border-border-destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
Display name
|
||||
</label>
|
||||
<Input
|
||||
id={displayNameInputId}
|
||||
className="h-10 text-[13px]"
|
||||
placeholder="Friendly provider label"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
disabled={
|
||||
providerConfigsUnavailable || isProviderMutationPending
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<label
|
||||
htmlFor={apiKeyInputId}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
API key{" "}
|
||||
{!providerConfig && (
|
||||
<span className="text-xs text-content-destructive font-bold">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
id={apiKeyInputId}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
className="h-10 text-[13px]"
|
||||
placeholder={
|
||||
providerConfig
|
||||
? "Leave blank to keep existing key"
|
||||
: "Paste provider API key"
|
||||
}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
disabled={
|
||||
providerConfigsUnavailable || isProviderMutationPending
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<label
|
||||
htmlFor={baseURLInputId}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
Base URL
|
||||
</label>
|
||||
<Input
|
||||
id={baseURLInputId}
|
||||
className="h-10 text-[13px]"
|
||||
placeholder="https://api.example.com/v1"
|
||||
value={baseURLValue}
|
||||
onChange={(e) => setBaseURLValue(e.target.value)}
|
||||
disabled={
|
||||
providerConfigsUnavailable || isProviderMutationPending
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 border-t border-border pt-3">
|
||||
<Button size="sm" type="submit" disabled={!canSave}>
|
||||
Delete
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<Button size="lg" type="submit" disabled={!canSave}>
|
||||
{isProviderMutationPending && (
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{providerConfig ? "Save changes" : "Create provider config"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
isOpen={showDeleteDialog}
|
||||
onCancel={() => setShowDeleteDialog(false)}
|
||||
onConfirm={() => {
|
||||
if (providerConfig) {
|
||||
void onDeleteProvider(providerConfig.id).finally(() =>
|
||||
setShowDeleteDialog(false),
|
||||
);
|
||||
}
|
||||
}}
|
||||
entity="provider"
|
||||
name={providerState.label}
|
||||
confirmLoading={isProviderMutationPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Field wrapper ──────────────────────────────────────────────
|
||||
|
||||
type ProviderFieldProps = {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const ProviderField: FC<ProviderFieldProps> = ({
|
||||
label,
|
||||
htmlFor,
|
||||
required,
|
||||
description,
|
||||
children,
|
||||
}) => (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="text-sm font-medium text-content-primary"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{required && (
|
||||
<span className="text-xs font-bold text-content-destructive">*</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="m-0 text-xs text-content-secondary">{description}</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,44 +14,41 @@ const providerIconMap: Record<string, string> = {
|
||||
gemini: "/icon/gemini.svg",
|
||||
};
|
||||
|
||||
// Some provider SVGs (e.g. OpenAI) are pure black and need
|
||||
// inversion in dark mode to remain visible.
|
||||
const darkInvertProviders = new Set(["openai"]);
|
||||
|
||||
type ProviderIconProps = {
|
||||
provider: string;
|
||||
className?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export const ProviderIcon: FC<ProviderIconProps> = ({
|
||||
provider,
|
||||
className,
|
||||
active,
|
||||
}) => {
|
||||
const normalized = normalizeProvider(provider);
|
||||
const iconPath = providerIconMap[normalized];
|
||||
if (iconPath) {
|
||||
return (
|
||||
<ExternalImage
|
||||
src={iconPath}
|
||||
alt={`${formatProviderLabel(provider)} logo`}
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
!active && "grayscale opacity-50",
|
||||
darkInvertProviders.has(normalized) && "dark:invert",
|
||||
"flex shrink-0 items-center justify-center rounded-full bg-surface-secondary",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<ExternalImage
|
||||
src={iconPath}
|
||||
alt={`${formatProviderLabel(provider)} logo`}
|
||||
className="h-3/5 w-3/5"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ServerIcon
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
active ? "text-content-primary" : "text-content-secondary",
|
||||
"flex shrink-0 items-center justify-center rounded-full bg-surface-secondary",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
>
|
||||
<ServerIcon className="h-3/5 w-3/5 text-content-secondary" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleTrigger,
|
||||
} from "components/Collapsible/Collapsible";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { CheckCircleIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { SectionHeader } from "../SectionHeader";
|
||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||
import { ProviderForm } from "./ProviderForm";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
|
||||
const getProviderModelsLabel = (providerState: ProviderState): string => {
|
||||
if (providerState.modelConfigs.length > 0) {
|
||||
return `${providerState.modelConfigs.length} configured model${providerState.modelConfigs.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
if (providerState.catalogModelCount > 0) {
|
||||
return `${providerState.catalogModelCount} catalog model${providerState.catalogModelCount === 1 ? "" : "s"}`;
|
||||
}
|
||||
return "No models configured";
|
||||
};
|
||||
type ProviderView = { mode: "list" } | { mode: "detail"; provider: string };
|
||||
|
||||
type ProvidersSectionProps = {
|
||||
sectionLabel?: string;
|
||||
providerStates: readonly ProviderState[];
|
||||
providerConfigsUnavailable: boolean;
|
||||
isProviderMutationPending: boolean;
|
||||
@@ -32,118 +21,123 @@ type ProvidersSectionProps = {
|
||||
providerConfigId: string,
|
||||
req: TypesGen.UpdateChatProviderConfigRequest,
|
||||
) => Promise<unknown>;
|
||||
onDeleteProvider: (providerConfigId: string) => Promise<void>;
|
||||
onSelectedProviderChange: (provider: string) => void;
|
||||
};
|
||||
|
||||
export const ProvidersSection: FC<ProvidersSectionProps> = ({
|
||||
sectionLabel,
|
||||
providerStates,
|
||||
providerConfigsUnavailable,
|
||||
isProviderMutationPending,
|
||||
onCreateProvider,
|
||||
onUpdateProvider,
|
||||
onDeleteProvider,
|
||||
onSelectedProviderChange,
|
||||
}) => {
|
||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||
const [view, setView] = useState<ProviderView>({ mode: "list" });
|
||||
|
||||
// Reset expanded provider when available providers change.
|
||||
useEffect(() => {
|
||||
setExpandedProvider((current) => {
|
||||
if (current && providerStates.some((ps) => ps.provider === current)) {
|
||||
return current;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}, [providerStates]);
|
||||
// ── Detail view ───────────────────────────────────────────
|
||||
const detailProvider =
|
||||
view.mode === "detail"
|
||||
? providerStates.find((ps) => ps.provider === view.provider)
|
||||
: undefined;
|
||||
|
||||
// Provider disappeared (e.g. data refreshed) — fall back to list.
|
||||
if (view.mode === "detail" && !detailProvider) {
|
||||
setView({ mode: "list" });
|
||||
}
|
||||
|
||||
if (view.mode === "detail" && detailProvider) {
|
||||
return (
|
||||
<ProviderForm
|
||||
providerState={detailProvider}
|
||||
providerConfigsUnavailable={providerConfigsUnavailable}
|
||||
isProviderMutationPending={isProviderMutationPending}
|
||||
onCreateProvider={onCreateProvider}
|
||||
onUpdateProvider={onUpdateProvider}
|
||||
onDeleteProvider={async (id) => {
|
||||
await onDeleteProvider(id);
|
||||
setView({ mode: "list" });
|
||||
}}
|
||||
onBack={() => setView({ mode: "list" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── List view ─────────────────────────────────────────────
|
||||
|
||||
if (providerStates.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-primary p-4 text-[13px] text-content-secondary">
|
||||
<div className="rounded-lg border border-dashed border-border bg-surface-primary p-6 text-center text-[13px] text-content-secondary">
|
||||
No provider types were returned by the backend.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{providerStates.map((providerState) => {
|
||||
const isExpanded = expandedProvider === providerState.provider;
|
||||
const modelsLabel = getProviderModelsLabel(providerState);
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
<>
|
||||
{sectionLabel && (
|
||||
<SectionHeader
|
||||
label={sectionLabel}
|
||||
description="Configure AI providers to use with Agents."
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{providerStates.map((providerState, i) => (
|
||||
<div
|
||||
key={providerState.provider}
|
||||
open={isExpanded}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setExpandedProvider(nextOpen ? providerState.provider : null);
|
||||
if (nextOpen) {
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={providerState.label}
|
||||
onClick={() => {
|
||||
onSelectedProviderChange(providerState.provider);
|
||||
setView({
|
||||
mode: "detail",
|
||||
provider: providerState.provider,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
onSelectedProviderChange(providerState.provider);
|
||||
setView({
|
||||
mode: "detail",
|
||||
provider: providerState.provider,
|
||||
});
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
onSelectedProviderChange(providerState.provider);
|
||||
setView({
|
||||
mode: "detail",
|
||||
provider: providerState.provider,
|
||||
});
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3.5 px-3 py-3 transition-colors hover:bg-surface-secondary/30",
|
||||
i > 0 && "border-0 border-t border-solid border-border/50",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border-default bg-surface-primary shadow-sm transition-all",
|
||||
isExpanded &&
|
||||
"border-border-default bg-surface-secondary/30 shadow-md",
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
className={cn(
|
||||
"h-auto w-full justify-between gap-4 rounded-[inherit] px-5 py-3.5 text-left shadow-none",
|
||||
isExpanded
|
||||
? "bg-surface-secondary/30 hover:bg-surface-secondary/30"
|
||||
: "hover:bg-surface-tertiary/30",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<ProviderIcon
|
||||
provider={providerState.provider}
|
||||
className="h-7 w-7"
|
||||
active={providerState.hasEffectiveAPIKey}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
"truncate text-[15px] font-semibold",
|
||||
providerState.hasEffectiveAPIKey
|
||||
? "text-content-primary"
|
||||
: "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{providerState.label}
|
||||
</span>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-content-secondary">
|
||||
<span className="truncate">{modelsLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 text-content-secondary transition-transform duration-200",
|
||||
isExpanded && "rotate-90 text-content-primary",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{isExpanded && (
|
||||
<ProviderForm
|
||||
provider={providerState.provider}
|
||||
providerConfig={providerState.providerConfig}
|
||||
baseURL={providerState.baseURL}
|
||||
isEnvPreset={providerState.isEnvPreset}
|
||||
providerConfigsUnavailable={providerConfigsUnavailable}
|
||||
isProviderMutationPending={isProviderMutationPending}
|
||||
onCreateProvider={onCreateProvider}
|
||||
onUpdateProvider={onUpdateProvider}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ProviderIcon
|
||||
provider={providerState.provider}
|
||||
className="h-8 w-8 shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-[15px] font-medium text-content-primary">
|
||||
{providerState.label}
|
||||
</span>
|
||||
{providerState.hasEffectiveAPIKey ? (
|
||||
<CheckCircleIcon className="h-4 w-4 shrink-0 text-content-success" />
|
||||
) : (
|
||||
<CircleIcon className="h-4 w-4 shrink-0 text-content-secondary opacity-40" />
|
||||
)}
|
||||
<ChevronRightIcon className="h-5 w-5 shrink-0 text-content-secondary" />
|
||||
</div>
|
||||
))}{" "}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import type * as TypesGen from "api/typesGenerated";
|
||||
import type { ProviderState } from "./ChatModelAdminPanel";
|
||||
|
||||
type ProviderModelConfigSchemaReference = {
|
||||
modelConfig: TypesGen.ChatModelCallConfig;
|
||||
notes?: readonly string[];
|
||||
};
|
||||
|
||||
const modelConfigSchemaByProvider: Record<
|
||||
string,
|
||||
ProviderModelConfigSchemaReference
|
||||
> = {
|
||||
openai: {
|
||||
modelConfig: {
|
||||
max_output_tokens: 32000,
|
||||
temperature: 0.2,
|
||||
top_p: 0.95,
|
||||
top_k: 40,
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0,
|
||||
provider_options: {
|
||||
openai: {
|
||||
reasoning_effort: "high",
|
||||
parallel_tool_calls: true,
|
||||
text_verbosity: "low",
|
||||
service_tier: "auto",
|
||||
user: "end-user-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: ["Responses API models may also use reasoning_summary and include."],
|
||||
},
|
||||
azure: {
|
||||
modelConfig: {
|
||||
max_output_tokens: 32000,
|
||||
provider_options: {
|
||||
openai: {
|
||||
reasoning_effort: "high",
|
||||
parallel_tool_calls: true,
|
||||
user: "end-user-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: ["Azure uses OpenAI provider option keys in Fantasy."],
|
||||
},
|
||||
anthropic: {
|
||||
modelConfig: {
|
||||
max_output_tokens: 32000,
|
||||
provider_options: {
|
||||
anthropic: {
|
||||
effort: "medium",
|
||||
thinking: { budget_tokens: 4000 },
|
||||
send_reasoning: true,
|
||||
disable_parallel_tool_use: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
bedrock: {
|
||||
modelConfig: {
|
||||
max_output_tokens: 32000,
|
||||
provider_options: {
|
||||
anthropic: {
|
||||
effort: "medium",
|
||||
thinking: { budget_tokens: 4000 },
|
||||
send_reasoning: true,
|
||||
disable_parallel_tool_use: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: ["Bedrock uses Anthropic option keys in Fantasy."],
|
||||
},
|
||||
google: {
|
||||
modelConfig: {
|
||||
max_output_tokens: 32000,
|
||||
provider_options: {
|
||||
google: {
|
||||
thinking_config: {
|
||||
thinking_budget: 1024,
|
||||
include_thoughts: true,
|
||||
},
|
||||
safety_settings: [
|
||||
{
|
||||
category: "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
threshold: "BLOCK_ONLY_HIGH",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
openaicompat: {
|
||||
modelConfig: {
|
||||
max_output_tokens: 32000,
|
||||
provider_options: {
|
||||
openaicompat: {
|
||||
reasoning_effort: "medium",
|
||||
user: "end-user-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
openrouter: {
|
||||
modelConfig: {
|
||||
max_output_tokens: 32000,
|
||||
provider_options: {
|
||||
openrouter: {
|
||||
reasoning: {
|
||||
enabled: true,
|
||||
effort: "medium",
|
||||
max_tokens: 2048,
|
||||
exclude: false,
|
||||
},
|
||||
parallel_tool_calls: true,
|
||||
include_usage: true,
|
||||
user: "end-user-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
vercel: {
|
||||
modelConfig: {
|
||||
max_output_tokens: 32000,
|
||||
provider_options: {
|
||||
vercel: {
|
||||
reasoning: {
|
||||
enabled: true,
|
||||
effort: "medium",
|
||||
max_tokens: 2048,
|
||||
exclude: false,
|
||||
},
|
||||
parallel_tool_calls: true,
|
||||
user: "end-user-id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getModelConfigSchemaReference = (
|
||||
providerState: ProviderState | null,
|
||||
) => {
|
||||
const providerLabel = providerState?.label ?? "Provider";
|
||||
const normalizedProvider = (providerState?.provider ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const providerConfigSchema = modelConfigSchemaByProvider[normalizedProvider];
|
||||
const modelConfigTemplate = providerConfigSchema?.modelConfig ?? {};
|
||||
const notes = providerConfigSchema?.notes ?? [
|
||||
"No provider-specific options are documented for this provider yet.",
|
||||
];
|
||||
|
||||
const schema: TypesGen.CreateChatModelConfigRequest = {
|
||||
provider: normalizedProvider || "<provider>",
|
||||
model: "<model-id>",
|
||||
context_limit: 200000,
|
||||
compression_threshold: 70,
|
||||
model_config: modelConfigTemplate,
|
||||
};
|
||||
|
||||
return {
|
||||
providerLabel,
|
||||
notes,
|
||||
schemaJSON: JSON.stringify(schema, null, 2),
|
||||
};
|
||||
};
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "components/Dialog/Dialog";
|
||||
import { ScrollArea } from "components/ScrollArea/ScrollArea";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { BoxesIcon, KeyRoundIcon, UserIcon, XIcon } from "lucide-react";
|
||||
import { type FC, type FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import { cn } from "utils/cn";
|
||||
import { ChatModelAdminPanel } from "./ChatModelAdminPanel/ChatModelAdminPanel";
|
||||
import { SectionHeader } from "./SectionHeader";
|
||||
|
||||
type ConfigureAgentsSection = "providers" | "system-prompt" | "models";
|
||||
|
||||
@@ -92,7 +92,7 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="grid h-[min(88dvh,720px)] max-w-4xl grid-cols-1 gap-0 overflow-hidden p-0 md:grid-cols-[200px_minmax(0,1fr)]">
|
||||
<DialogContent className="grid h-[min(88dvh,720px)] max-w-4xl grid-cols-1 gap-0 overflow-hidden p-0 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||
{/* Visually hidden for accessibility */}
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Configure Agents</DialogTitle>
|
||||
@@ -102,14 +102,14 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
|
||||
</DialogHeader>
|
||||
|
||||
{/* Sidebar */}
|
||||
<nav className="flex flex-row gap-0.5 overflow-x-auto border-b border-border p-2 md:flex-col md:overflow-x-visible md:border-b-0 md:border-r md:p-3">
|
||||
<nav className="flex flex-row gap-0.5 overflow-x-auto border-b border-border bg-surface-secondary/40 p-2 md:flex-col md:gap-0.5 md:overflow-x-visible md:border-b-0 md:border-r md:p-4">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
className="mb-2 h-8 w-8 shrink-0 border-none bg-transparent shadow-none hover:bg-surface-tertiary/30"
|
||||
size="icon-lg"
|
||||
className="mb-3 shrink-0 border-none bg-transparent shadow-none hover:bg-surface-tertiary/50"
|
||||
>
|
||||
<XIcon className="h-[18px] w-[18px] text-content-secondary" />
|
||||
<XIcon className="text-content-secondary" />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
@@ -121,39 +121,32 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
|
||||
key={section.id}
|
||||
variant="subtle"
|
||||
className={cn(
|
||||
"h-auto justify-start gap-2.5 rounded-lg border-none px-3 py-2 text-left shadow-none",
|
||||
"h-auto justify-start gap-3 rounded-lg border-none px-3 py-1.5 text-left shadow-none",
|
||||
isActive
|
||||
? "bg-surface-tertiary/50 text-content-primary hover:bg-surface-tertiary/50"
|
||||
? "bg-surface-tertiary/60 text-content-primary hover:bg-surface-tertiary/60"
|
||||
: "bg-transparent text-content-secondary hover:bg-surface-tertiary/30 hover:text-content-primary",
|
||||
)}
|
||||
onClick={() => setUserActiveSection(section.id)}
|
||||
>
|
||||
<SectionIcon className="h-[18px] w-[18px] shrink-0" />
|
||||
<span className="text-[13px] font-medium">{section.label}</span>
|
||||
<SectionIcon className="h-5 w-5 shrink-0" />
|
||||
<span className="text-sm font-medium">{section.label}</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex min-h-0 flex-col pt-5">
|
||||
<h2 className="m-0 px-6 text-xl font-semibold text-content-primary">
|
||||
{configureSectionOptions.find((s) => s.id === activeSection)
|
||||
?.label ?? "Settings"}
|
||||
</h2>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1" viewportClassName="px-6 pb-6">
|
||||
{activeSection === "providers" && canManageChatModelConfigs && (
|
||||
<ChatModelAdminPanel section="providers" />
|
||||
)}
|
||||
{activeSection === "system-prompt" && canSetSystemPrompt && (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-6 py-5">
|
||||
{activeSection === "providers" && canManageChatModelConfigs && (
|
||||
<ChatModelAdminPanel section="providers" sectionLabel="Providers" />
|
||||
)}
|
||||
{activeSection === "system-prompt" && canSetSystemPrompt && (
|
||||
<>
|
||||
<SectionHeader label="Behavior" />
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => void onSaveSystemPrompt(event)}
|
||||
>
|
||||
<p className="m-0 text-[13px] leading-relaxed text-content-secondary">
|
||||
Configure how the AI agent behaves across this deployment.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<h3 className="m-0 text-[13px] font-semibold text-content-primary">
|
||||
System Prompt
|
||||
@@ -191,11 +184,11 @@ export const ConfigureAgentsDialog: FC<ConfigureAgentsDialogProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{activeSection === "models" && canManageChatModelConfigs && (
|
||||
<ChatModelAdminPanel section="models" />
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
{activeSection === "models" && canManageChatModelConfigs && (
|
||||
<ChatModelAdminPanel section="models" sectionLabel="Models" />
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
type SectionHeaderProps = {
|
||||
label: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
};
|
||||
|
||||
export const SectionHeader: FC<SectionHeaderProps> = ({
|
||||
label,
|
||||
description,
|
||||
action,
|
||||
}) => (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="m-0 text-lg font-medium text-content-primary">
|
||||
{label}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="m-0 text-sm text-content-secondary">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
</>
|
||||
);
|
||||
Reference in New Issue
Block a user