mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: polish model config form UI (#24047)
Polishes the AI model configuration form (add/edit model) with tighter
layout and better input affordances.
**Frontend changes:**
- Replace "Unset" with "Default" in select dropdowns to communicate
system fallback
- Show pricing fields inline instead of behind a collapsible toggle
- Use flat section dividers (`border-t`) instead of bordered fieldsets
- Move field descriptions into info-icon tooltips to fix input
misalignment
- Add InputGroup adornments: `$` prefix + `/1M` suffix on pricing,
`tokens` suffix on token fields, `%` suffix on compression threshold,
range placeholders on temperature/penalty fields
- Shorter pricing labels (Input, Output, Cache Read, Cache Write)
- Compact JSON textareas (1-row height, resizable)
- Smart grid layouts by field type (3-col provider, 4-col pricing, 3-col
advanced)
- Boolean fields render as a segmented control (Default · On · Off)
instead of a dropdown
**Backend changes:**
- Add `enum` tags to OpenAI `service_tier`
(`auto,default,flex,scale,priority`) and `reasoning_summary`
(`auto,concise,detailed`) so they render as select dropdowns instead of
free-text inputs
> 🤖 Generated by Coder Agents
This commit is contained in:
+6
-6
@@ -626,7 +626,7 @@ type ChatModelOpenAIProviderOptions struct {
|
||||
ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty" description:"Whether the model may make multiple tool calls in parallel"`
|
||||
User *string `json:"user,omitempty" description:"Unique identifier for the end user for abuse monitoring" hidden:"true"`
|
||||
ReasoningEffort *string `json:"reasoning_effort,omitempty" description:"Controls the level of reasoning effort" enum:"none,minimal,low,medium,high,xhigh"`
|
||||
ReasoningSummary *string `json:"reasoning_summary,omitempty" description:"Controls whether reasoning tokens are summarized in the response"`
|
||||
ReasoningSummary *string `json:"reasoning_summary,omitempty" description:"Controls whether reasoning tokens are summarized in the response" enum:"auto,concise,detailed"`
|
||||
MaxCompletionTokens *int64 `json:"max_completion_tokens,omitempty" description:"Upper bound on tokens the model may generate"`
|
||||
TextVerbosity *string `json:"text_verbosity,omitempty" description:"Controls the verbosity of the text response" enum:"low,medium,high"`
|
||||
Prediction map[string]any `json:"prediction,omitempty" description:"Predicted output content to speed up responses" hidden:"true"`
|
||||
@@ -634,12 +634,12 @@ type ChatModelOpenAIProviderOptions struct {
|
||||
Metadata map[string]any `json:"metadata,omitempty" description:"Arbitrary metadata to attach to the request" hidden:"true"`
|
||||
PromptCacheKey *string `json:"prompt_cache_key,omitempty" description:"Key for enabling cross-request prompt caching"`
|
||||
SafetyIdentifier *string `json:"safety_identifier,omitempty" description:"Developer-specific safety identifier for the request" hidden:"true"`
|
||||
ServiceTier *string `json:"service_tier,omitempty" description:"Latency tier to use for processing the request"`
|
||||
ServiceTier *string `json:"service_tier,omitempty" description:"Latency tier to use for processing the request" enum:"auto,default,flex,scale,priority"`
|
||||
StructuredOutputs *bool `json:"structured_outputs,omitempty" description:"Whether to enable structured JSON output mode" hidden:"true"`
|
||||
StrictJSONSchema *bool `json:"strict_json_schema,omitempty" description:"Whether to enforce strict adherence to the JSON schema" hidden:"true"`
|
||||
WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable OpenAI web search tool for grounding responses with real-time information"`
|
||||
SearchContextSize *string `json:"search_context_size,omitempty" description:"Amount of search context to use" enum:"low,medium,high"`
|
||||
AllowedDomains []string `json:"allowed_domains,omitempty" description:"Restrict web search to these domains"`
|
||||
AllowedDomains []string `json:"allowed_domains,omitempty" label:"Web Search: Allowed Domains" description:"Restrict web search to these domains"`
|
||||
}
|
||||
|
||||
// ChatModelAnthropicThinkingOptions configures Anthropic thinking budget.
|
||||
@@ -651,11 +651,11 @@ type ChatModelAnthropicThinkingOptions struct {
|
||||
type ChatModelAnthropicProviderOptions struct {
|
||||
SendReasoning *bool `json:"send_reasoning,omitempty" description:"Whether to include reasoning content in the response"`
|
||||
Thinking *ChatModelAnthropicThinkingOptions `json:"thinking,omitempty" description:"Configuration for extended thinking"`
|
||||
Effort *string `json:"effort,omitempty" description:"Controls the level of reasoning effort" enum:"low,medium,high,max"`
|
||||
Effort *string `json:"effort,omitempty" label:"Reasoning Effort" description:"Controls the level of reasoning effort" enum:"low,medium,high,max"`
|
||||
DisableParallelToolUse *bool `json:"disable_parallel_tool_use,omitempty" description:"Whether to disable parallel tool execution"`
|
||||
WebSearchEnabled *bool `json:"web_search_enabled,omitempty" description:"Enable Anthropic web search tool for grounding responses with real-time information"`
|
||||
AllowedDomains []string `json:"allowed_domains,omitempty" description:"Restrict web search to these domains (cannot be used with blocked_domains)"`
|
||||
BlockedDomains []string `json:"blocked_domains,omitempty" description:"Block web search on these domains (cannot be used with allowed_domains)"`
|
||||
AllowedDomains []string `json:"allowed_domains,omitempty" label:"Web Search: Allowed Domains" description:"Restrict web search to these domains (cannot be used with blocked_domains)"`
|
||||
BlockedDomains []string `json:"blocked_domains,omitempty" label:"Web Search: Blocked Domains" description:"Block web search on these domains (cannot be used with allowed_domains)"`
|
||||
}
|
||||
|
||||
// ChatModelGoogleThinkingConfig configures Google thinking behavior.
|
||||
|
||||
@@ -18,6 +18,7 @@ type SchemaField struct {
|
||||
GoName string `json:"go_name"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
InputType string `json:"input_type"`
|
||||
@@ -135,6 +136,7 @@ func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGro
|
||||
|
||||
typeName := goTypeToSchemaType(f.Type)
|
||||
description := f.Tag.Get("description")
|
||||
label := f.Tag.Get("label")
|
||||
enumTag := f.Tag.Get("enum")
|
||||
|
||||
var enumValues []string
|
||||
@@ -150,6 +152,7 @@ func extractFields(t reflect.Type, prefix string, skip map[string]bool) FieldGro
|
||||
GoName: goFieldPath(prefix, f.Name, t, fullJSONName),
|
||||
Type: typeName,
|
||||
Description: description,
|
||||
Label: label,
|
||||
Required: required,
|
||||
Enum: enumValues,
|
||||
InputType: inputType,
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface FieldSchema {
|
||||
type: "string" | "integer" | "number" | "boolean" | "array" | "object";
|
||||
/** Human-readable description of the field. May be absent for some fields. */
|
||||
description?: string;
|
||||
/** Optional display label override. When absent, derive from json_name. */
|
||||
label?: string;
|
||||
/** Whether this field is required when configuring the provider. */
|
||||
required: boolean;
|
||||
/** Hint for how the frontend should render the input control. */
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
"go_name": "Effort",
|
||||
"type": "string",
|
||||
"description": "Controls the level of reasoning effort",
|
||||
"label": "Reasoning Effort",
|
||||
"required": false,
|
||||
"enum": ["low", "medium", "high", "max"],
|
||||
"input_type": "select"
|
||||
@@ -132,6 +133,7 @@
|
||||
"go_name": "AllowedDomains",
|
||||
"type": "array",
|
||||
"description": "Restrict web search to these domains (cannot be used with blocked_domains)",
|
||||
"label": "Web Search: Allowed Domains",
|
||||
"required": false,
|
||||
"input_type": "json"
|
||||
},
|
||||
@@ -140,6 +142,7 @@
|
||||
"go_name": "BlockedDomains",
|
||||
"type": "array",
|
||||
"description": "Block web search on these domains (cannot be used with allowed_domains)",
|
||||
"label": "Web Search: Blocked Domains",
|
||||
"required": false,
|
||||
"input_type": "json"
|
||||
}
|
||||
@@ -286,7 +289,8 @@
|
||||
"type": "string",
|
||||
"description": "Controls whether reasoning tokens are summarized in the response",
|
||||
"required": false,
|
||||
"input_type": "input"
|
||||
"enum": ["auto", "concise", "detailed"],
|
||||
"input_type": "select"
|
||||
},
|
||||
{
|
||||
"json_name": "max_completion_tokens",
|
||||
@@ -354,7 +358,8 @@
|
||||
"type": "string",
|
||||
"description": "Latency tier to use for processing the request",
|
||||
"required": false,
|
||||
"input_type": "input"
|
||||
"enum": ["auto", "default", "flex", "scale", "priority"],
|
||||
"input_type": "select"
|
||||
},
|
||||
{
|
||||
"json_name": "structured_outputs",
|
||||
@@ -396,6 +401,7 @@
|
||||
"go_name": "AllowedDomains",
|
||||
"type": "array",
|
||||
"description": "Restrict web search to these domains",
|
||||
"label": "Web Search: Allowed Domains",
|
||||
"required": false,
|
||||
"input_type": "json"
|
||||
}
|
||||
|
||||
+24
-8
@@ -827,6 +827,14 @@ const openAddModelForm = async (
|
||||
});
|
||||
};
|
||||
|
||||
/** Expand a collapsible section by clicking its header button. */
|
||||
const expandSection = async (body: ReturnType<typeof within>, name: string) => {
|
||||
const btn = await body.findByRole("button", {
|
||||
name: new RegExp(name, "i"),
|
||||
});
|
||||
await userEvent.click(btn);
|
||||
};
|
||||
|
||||
export const NoModelConfigByDefault: Story = {
|
||||
args: {
|
||||
section: "models" as ChatModelAdminSection,
|
||||
@@ -900,18 +908,18 @@ export const SubmitModelConfigExplicitly: Story = {
|
||||
"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"));
|
||||
// Max output tokens is under "Advanced".
|
||||
await expandSection(body, "Advanced");
|
||||
await userEvent.type(
|
||||
await body.findByLabelText(/Max output tokens/i),
|
||||
"32000",
|
||||
);
|
||||
await userEvent.click(
|
||||
body.getByRole("combobox", {
|
||||
name: "Reasoning Effort",
|
||||
}),
|
||||
);
|
||||
await userEvent.click(await body.findByRole("option", { name: "high" }));
|
||||
// Reasoning Effort is a provider option under "Provider Configuration".
|
||||
await expandSection(body, "Provider Configuration");
|
||||
const effortGroup = await body.findByRole("radiogroup", {
|
||||
name: "Reasoning Effort",
|
||||
});
|
||||
await userEvent.click(within(effortGroup).getByText("High"));
|
||||
|
||||
await userEvent.click(body.getByRole("button", { name: "Add model" }));
|
||||
await waitFor(() => {
|
||||
@@ -1002,6 +1010,7 @@ export const ModelFormOpenAI: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await openAddModelForm(body, "OpenAI");
|
||||
await expandSection(body, "Provider Configuration");
|
||||
await expect(
|
||||
await body.findByLabelText(/Reasoning Effort/i),
|
||||
).toBeInTheDocument();
|
||||
@@ -1016,6 +1025,7 @@ export const ModelFormAnthropic: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await openAddModelForm(body, "Anthropic");
|
||||
await expandSection(body, "Provider Configuration");
|
||||
await expect(
|
||||
await body.findByLabelText(/Send Reasoning/i),
|
||||
).toBeInTheDocument();
|
||||
@@ -1030,6 +1040,7 @@ export const ModelFormGoogle: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await openAddModelForm(body, "Google");
|
||||
await expandSection(body, "Provider Configuration");
|
||||
await expect(
|
||||
await body.findByLabelText(/Thinking Config Thinking Budget/i),
|
||||
).toBeInTheDocument();
|
||||
@@ -1044,6 +1055,7 @@ export const ModelFormOpenAICompat: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await openAddModelForm(body, "OpenAI-compatible");
|
||||
await expandSection(body, "Provider Configuration");
|
||||
await expect(
|
||||
await body.findByLabelText(/Reasoning Effort/i),
|
||||
).toBeInTheDocument();
|
||||
@@ -1055,6 +1067,7 @@ export const ModelFormOpenRouter: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await openAddModelForm(body, "OpenRouter");
|
||||
await expandSection(body, "Provider Configuration");
|
||||
await expect(
|
||||
await body.findByLabelText(/Reasoning Enabled/i),
|
||||
).toBeInTheDocument();
|
||||
@@ -1069,6 +1082,7 @@ export const ModelFormVercel: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await openAddModelForm(body, "Vercel AI Gateway");
|
||||
await expandSection(body, "Provider Configuration");
|
||||
await expect(
|
||||
await body.findByLabelText(/Reasoning Enabled/i),
|
||||
).toBeInTheDocument();
|
||||
@@ -1083,6 +1097,7 @@ export const ModelFormAzure: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await openAddModelForm(body, "Azure OpenAI");
|
||||
await expandSection(body, "Provider Configuration");
|
||||
// Azure aliases to OpenAI fields.
|
||||
await expect(
|
||||
await body.findByLabelText(/Reasoning Effort/i),
|
||||
@@ -1098,6 +1113,7 @@ export const ModelFormBedrock: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await openAddModelForm(body, "AWS Bedrock");
|
||||
await expandSection(body, "Provider Configuration");
|
||||
// Bedrock aliases to Anthropic fields.
|
||||
await expect(
|
||||
await body.findByLabelText(/Send Reasoning/i),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type FormikContextType, getIn } from "formik";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
type FieldSchema,
|
||||
@@ -9,6 +10,11 @@ import {
|
||||
toFormFieldKey,
|
||||
} from "#/api/chatModelOptions";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "#/components/InputGroup/InputGroup";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import {
|
||||
Select,
|
||||
@@ -18,6 +24,11 @@ import {
|
||||
SelectValue,
|
||||
} from "#/components/Select/Select";
|
||||
import { Textarea } from "#/components/Textarea/Textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { normalizeProvider } from "./helpers";
|
||||
import type {
|
||||
@@ -34,16 +45,61 @@ const unsetSelectValue = "__unset__";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
/** Short display labels for pricing fields to avoid overly verbose names. */
|
||||
const shortLabelOverrides: Record<string, string> = {
|
||||
"cost.input_price_per_million_tokens": "Input",
|
||||
"cost.output_price_per_million_tokens": "Output",
|
||||
"cost.cache_read_price_per_million_tokens": "Cache Read",
|
||||
"cost.cache_write_price_per_million_tokens": "Cache Write",
|
||||
};
|
||||
|
||||
/**
|
||||
* Suffix units displayed inside the input control. When present,
|
||||
* the field renders as an InputGroup with the suffix appended.
|
||||
*/
|
||||
const fieldSuffix: Record<string, string> = {
|
||||
max_output_tokens: "tokens",
|
||||
top_k: "tokens",
|
||||
"thinking.budget_tokens": "tokens",
|
||||
"thinking_config.thinking_budget": "tokens",
|
||||
max_completion_tokens: "tokens",
|
||||
"reasoning.max_tokens": "tokens",
|
||||
max_tool_calls: "calls",
|
||||
};
|
||||
|
||||
/**
|
||||
* Placeholder overrides with range hints for numeric fields
|
||||
* where the valid range is more useful than an empty box.
|
||||
*/
|
||||
const placeholderOverrides: Record<string, string> = {
|
||||
temperature: "0.0–2.0",
|
||||
top_p: "0.0–1.0",
|
||||
presence_penalty: "-2.0–2.0",
|
||||
frequency_penalty: "-2.0–2.0",
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a dot-and-underscore-separated json_name into a
|
||||
* human-readable label.
|
||||
* human-readable label. Uses short overrides for pricing fields
|
||||
* when available.
|
||||
*
|
||||
* @example
|
||||
* snakeToPrettyLabel("thinking.budget_tokens") // "Thinking Budget Tokens"
|
||||
* snakeToPrettyLabel("reasoning_effort") // "Reasoning Effort"
|
||||
*/
|
||||
function snakeToPrettyLabel(jsonName: string): string {
|
||||
return jsonName
|
||||
/** Capitalize the first letter of a string. */
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function snakeToPrettyLabel(field: FieldSchema): string {
|
||||
if (field.label) {
|
||||
return field.label;
|
||||
}
|
||||
if (shortLabelOverrides[field.json_name]) {
|
||||
return shortLabelOverrides[field.json_name];
|
||||
}
|
||||
return field.json_name
|
||||
.split(/[._]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
@@ -79,6 +135,30 @@ type FieldRenderContext = {
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
/** Label with an optional info tooltip for field descriptions. */
|
||||
const FieldLabel: FC<{
|
||||
htmlFor: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}> = ({ htmlFor, label, description }) => (
|
||||
<Label
|
||||
htmlFor={htmlFor}
|
||||
className="inline-flex items-center gap-1 text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
{label}
|
||||
{description && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="h-3 w-3 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Label>
|
||||
);
|
||||
|
||||
const InputField: FC<
|
||||
FieldRenderContext & {
|
||||
fieldKey: string;
|
||||
@@ -86,6 +166,7 @@ const InputField: FC<
|
||||
label: string;
|
||||
description?: string;
|
||||
placeholder: string;
|
||||
suffix?: string;
|
||||
}
|
||||
> = ({
|
||||
form,
|
||||
@@ -96,33 +177,48 @@ const InputField: FC<
|
||||
label,
|
||||
description,
|
||||
placeholder,
|
||||
suffix,
|
||||
}) => {
|
||||
const errorId = `${fieldKey}-error`;
|
||||
const fieldError = fieldErrors[errorKey ?? fieldKey];
|
||||
const fieldProps = form.getFieldProps(fieldKey);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={fieldKey}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="m-0 text-xs text-content-secondary">{description}</p>
|
||||
)}
|
||||
<Input
|
||||
|
||||
const inputEl = suffix ? (
|
||||
<InputGroup
|
||||
className={cn("h-9", fieldError && "border-border-destructive")}
|
||||
>
|
||||
<InputGroupInput
|
||||
id={fieldKey}
|
||||
className={cn(
|
||||
"h-9 min-w-0 text-[13px] placeholder:text-content-disabled",
|
||||
fieldError && "border-content-destructive",
|
||||
)}
|
||||
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
|
||||
placeholder={placeholder}
|
||||
{...fieldProps}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(fieldError)}
|
||||
aria-describedby={fieldError ? errorId : undefined}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="text-xs text-content-disabled">{suffix}</span>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
) : (
|
||||
<Input
|
||||
id={fieldKey}
|
||||
className={cn(
|
||||
"h-9 min-w-0 text-[13px] placeholder:text-content-disabled",
|
||||
fieldError && "border-content-destructive",
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
{...fieldProps}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(fieldError)}
|
||||
aria-describedby={fieldError ? errorId : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<FieldLabel htmlFor={fieldKey} label={label} description={description} />
|
||||
{inputEl}
|
||||
{fieldError && (
|
||||
<p id={errorId} className="m-0 text-xs text-content-destructive">
|
||||
{fieldError}
|
||||
@@ -155,15 +251,7 @@ const SelectField: FC<
|
||||
const currentValue = (getIn(form.values, fieldKey) as string) || "";
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={fieldKey}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="m-0 text-xs text-content-secondary">{description}</p>
|
||||
)}
|
||||
<FieldLabel htmlFor={fieldKey} label={label} description={description} />
|
||||
<Select
|
||||
value={currentValue || unsetSelectValue}
|
||||
onValueChange={(value) =>
|
||||
@@ -183,10 +271,10 @@ const SelectField: FC<
|
||||
aria-invalid={Boolean(fieldError)}
|
||||
aria-describedby={fieldError ? errorId : undefined}
|
||||
>
|
||||
<SelectValue placeholder="Unset" />
|
||||
<SelectValue placeholder="Default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={unsetSelectValue}>Unset</SelectItem>
|
||||
<SelectItem value={unsetSelectValue}>Default</SelectItem>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
@@ -203,6 +291,73 @@ const SelectField: FC<
|
||||
);
|
||||
};
|
||||
|
||||
const SegmentedField: FC<
|
||||
FieldRenderContext & {
|
||||
fieldKey: string;
|
||||
errorKey?: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
options: readonly { label: string; value: string }[];
|
||||
}
|
||||
> = ({
|
||||
form,
|
||||
fieldErrors,
|
||||
disabled,
|
||||
fieldKey,
|
||||
errorKey,
|
||||
label,
|
||||
description,
|
||||
options,
|
||||
}) => {
|
||||
const errorId = `${fieldKey}-error`;
|
||||
const fieldError = fieldErrors[errorKey ?? fieldKey];
|
||||
const currentValue = (getIn(form.values, fieldKey) as string) || "";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<FieldLabel htmlFor={fieldKey} label={label} description={description} />
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"flex h-9 items-stretch rounded-md border border-solid border-border p-0.5",
|
||||
fieldError && "border-content-destructive",
|
||||
)}
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const isActive = currentValue === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-8 flex-1 cursor-pointer rounded-[5px] border-0 px-3 text-[13px] font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-surface-secondary text-content-primary"
|
||||
: "bg-transparent text-content-secondary hover:text-content-primary",
|
||||
disabled && "pointer-events-none opacity-60",
|
||||
)}
|
||||
onClick={() =>
|
||||
void form.setFieldValue(fieldKey, isActive ? "" : opt.value)
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{fieldError && (
|
||||
<p id={errorId} className="m-0 text-xs text-content-destructive">
|
||||
{fieldError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const JSONField: FC<
|
||||
FieldRenderContext & {
|
||||
fieldKey: string;
|
||||
@@ -226,19 +381,12 @@ const JSONField: FC<
|
||||
const fieldProps = form.getFieldProps(fieldKey);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={fieldKey}
|
||||
className="text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
{description && (
|
||||
<p className="m-0 text-xs text-content-secondary">{description}</p>
|
||||
)}
|
||||
<FieldLabel htmlFor={fieldKey} label={label} description={description} />
|
||||
<Textarea
|
||||
id={fieldKey}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"min-h-[96px] font-mono text-xs placeholder:text-content-disabled",
|
||||
"min-h-0 resize-y font-mono text-xs leading-tight placeholder:text-content-disabled",
|
||||
fieldError && "border-content-destructive",
|
||||
)}
|
||||
placeholder={placeholder}
|
||||
@@ -276,7 +424,7 @@ const SchemaField: FC<SchemaFieldProps> = ({
|
||||
fieldErrors,
|
||||
disabled,
|
||||
}) => {
|
||||
const label = snakeToPrettyLabel(field.json_name);
|
||||
const label = snakeToPrettyLabel(field);
|
||||
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
|
||||
|
||||
switch (field.input_type) {
|
||||
@@ -288,12 +436,42 @@ const SchemaField: FC<SchemaFieldProps> = ({
|
||||
errorKey={errorKey}
|
||||
label={label}
|
||||
description={field.description}
|
||||
placeholder={placeholderForField(field)}
|
||||
placeholder={
|
||||
placeholderOverrides[field.json_name] ?? placeholderForField(field)
|
||||
}
|
||||
suffix={fieldSuffix[field.json_name]}
|
||||
/>
|
||||
);
|
||||
case "select": {
|
||||
const options: readonly string[] =
|
||||
field.enum ?? (field.type === "boolean" ? ["true", "false"] : []);
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<SegmentedField
|
||||
{...ctx}
|
||||
fieldKey={fieldKey}
|
||||
errorKey={errorKey}
|
||||
label={label}
|
||||
description={field.description}
|
||||
options={[
|
||||
{ label: "On", value: "true" },
|
||||
{ label: "Off", value: "false" },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const options: readonly string[] = field.enum ?? [];
|
||||
const maxSegmented = 6;
|
||||
if (options.length > 0 && options.length <= maxSegmented) {
|
||||
return (
|
||||
<SegmentedField
|
||||
{...ctx}
|
||||
fieldKey={fieldKey}
|
||||
errorKey={errorKey}
|
||||
label={label}
|
||||
description={field.description}
|
||||
options={options.map((v) => ({ label: capitalize(v), value: v }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SelectField
|
||||
{...ctx}
|
||||
@@ -323,6 +501,30 @@ const SchemaField: FC<SchemaFieldProps> = ({
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* How many grid columns a field should span in the 3-col layout.
|
||||
* 1 = default (inputs, booleans, small enums ≤3)
|
||||
* 3 = full-width (4+ option enums, json textareas)
|
||||
*/
|
||||
function colSpan(field: FieldSchema): 1 | 3 {
|
||||
if (field.input_type === "json") {
|
||||
return 3;
|
||||
}
|
||||
if (
|
||||
field.input_type === "select" &&
|
||||
field.type !== "boolean" &&
|
||||
(field.enum?.length ?? 0) > 3
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
const colSpanClass: Record<1 | 3, string | undefined> = {
|
||||
1: undefined,
|
||||
3: "sm:col-span-full",
|
||||
};
|
||||
|
||||
interface ModelConfigFieldsProps {
|
||||
provider: string;
|
||||
form: FormikContextType<ModelFormValues>;
|
||||
@@ -353,19 +555,24 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
|
||||
|
||||
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
|
||||
|
||||
// Sort wider fields to the end so compact fields fill the
|
||||
// grid first, keeping the layout dense.
|
||||
const sorted = [...fields].sort((a, b) => colSpan(a) - colSpan(b));
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
|
||||
{fields.map((field) => {
|
||||
<div className="grid min-w-0 gap-3 sm:grid-cols-3">
|
||||
{sorted.map((field) => {
|
||||
const fieldKey = `config.${toFormFieldKey(resolved, field.json_name)}`;
|
||||
const errorKey = toFormFieldKey(resolved, field.json_name);
|
||||
return (
|
||||
<SchemaField
|
||||
key={fieldKey}
|
||||
field={field}
|
||||
fieldKey={fieldKey}
|
||||
errorKey={errorKey}
|
||||
{...ctx}
|
||||
/>
|
||||
<div key={fieldKey} className={colSpanClass[colSpan(field)]}>
|
||||
<SchemaField
|
||||
field={field}
|
||||
fieldKey={fieldKey}
|
||||
errorKey={errorKey}
|
||||
{...ctx}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -379,8 +586,9 @@ export const ModelConfigFields: FC<ModelConfigFieldsProps> = ({
|
||||
const GeneralFieldsGroup: FC<
|
||||
ModelConfigFieldsProps & {
|
||||
fields: FieldSchema[];
|
||||
suppressDescriptions?: boolean;
|
||||
}
|
||||
> = ({ form, fieldErrors, disabled, fields }) => {
|
||||
> = ({ form, fieldErrors, disabled, fields, suppressDescriptions }) => {
|
||||
const ctx: FieldRenderContext = { form, fieldErrors, disabled };
|
||||
|
||||
return (
|
||||
@@ -393,7 +601,7 @@ const GeneralFieldsGroup: FC<
|
||||
.map(snakeToCamel)
|
||||
.join(".");
|
||||
const fieldKey = `config.${camelName}`;
|
||||
const label = snakeToPrettyLabel(field.json_name);
|
||||
const label = snakeToPrettyLabel(field);
|
||||
|
||||
return (
|
||||
<InputField
|
||||
@@ -402,8 +610,12 @@ const GeneralFieldsGroup: FC<
|
||||
fieldKey={fieldKey}
|
||||
errorKey={camelName}
|
||||
label={label}
|
||||
description={field.description}
|
||||
placeholder={placeholderForField(field)}
|
||||
description={suppressDescriptions ? undefined : field.description}
|
||||
placeholder={
|
||||
placeholderOverrides[field.json_name] ??
|
||||
placeholderForField(field)
|
||||
}
|
||||
suffix={fieldSuffix[field.json_name]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -412,25 +624,62 @@ const GeneralFieldsGroup: FC<
|
||||
};
|
||||
|
||||
/**
|
||||
* General pricing fields shown in the main form body so admins can
|
||||
* define optional pricing metadata without opening the advanced section.
|
||||
* Pricing fields rendered with $ prefix and /1M suffix using
|
||||
* InputGroup for a compact, readable layout.
|
||||
*/
|
||||
export const PricingModelConfigFields: FC<ModelConfigFieldsProps> = ({
|
||||
provider,
|
||||
form,
|
||||
fieldErrors,
|
||||
disabled,
|
||||
}) => {
|
||||
const fields = getVisibleGeneralFields().filter(({ json_name }) =>
|
||||
pricingFieldNames.has(json_name),
|
||||
);
|
||||
|
||||
return (
|
||||
<GeneralFieldsGroup
|
||||
provider={provider}
|
||||
form={form}
|
||||
fieldErrors={fieldErrors}
|
||||
disabled={disabled}
|
||||
fields={getVisibleGeneralFields().filter(({ json_name }) =>
|
||||
pricingFieldNames.has(json_name),
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
{fields.map((field) => {
|
||||
const camelName = field.json_name
|
||||
.split(".")
|
||||
.map(snakeToCamel)
|
||||
.join(".");
|
||||
const fieldKey = `config.${camelName}`;
|
||||
const label = snakeToPrettyLabel(field);
|
||||
const errorId = `${fieldKey}-error`;
|
||||
const fieldError = fieldErrors[camelName];
|
||||
const fieldProps = form.getFieldProps(fieldKey);
|
||||
|
||||
return (
|
||||
<div key={fieldKey} className="flex min-w-0 flex-col gap-1.5">
|
||||
<FieldLabel htmlFor={fieldKey} label={label} />
|
||||
<InputGroup
|
||||
className={cn("h-9", fieldError && "border-border-destructive")}
|
||||
>
|
||||
<InputGroupAddon align="inline-start">$</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id={fieldKey}
|
||||
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
|
||||
placeholder="0"
|
||||
{...fieldProps}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(fieldError)}
|
||||
aria-describedby={fieldError ? errorId : undefined}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="text-xs text-content-disabled">
|
||||
USD/1M tokens
|
||||
</span>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{fieldError && (
|
||||
<p id={errorId} className="m-0 text-xs text-content-destructive">
|
||||
{fieldError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
InfoIcon,
|
||||
PencilIcon,
|
||||
} from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import * as Yup from "yup";
|
||||
@@ -17,6 +19,11 @@ import {
|
||||
DialogTitle,
|
||||
} from "#/components/Dialog/Dialog";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "#/components/InputGroup/InputGroup";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import {
|
||||
Select,
|
||||
@@ -109,8 +116,9 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
}) => {
|
||||
const isEditing = Boolean(editingModel);
|
||||
const isDefaultModel = isEditing && editingModel?.is_default === true;
|
||||
const [showPricing, setShowPricing] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showPricing, setShowPricing] = useState(false);
|
||||
const [showProviderConfig, setShowProviderConfig] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
const canManageModels = Boolean(
|
||||
@@ -324,17 +332,30 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
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 className="inline-flex items-center gap-1">
|
||||
<div className="relative inline-grid">
|
||||
<span
|
||||
className="invisible col-start-1 row-start-1 whitespace-pre text-lg font-medium"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{form.values.displayName ||
|
||||
(isEditing
|
||||
? (editingModel?.model ?? "Model name")
|
||||
: "Model name")}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
{...form.getFieldProps("displayName")}
|
||||
disabled={isSaving}
|
||||
spellCheck={false}
|
||||
className="col-start-1 row-start-1 m-0 min-w-0 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>
|
||||
<PencilIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
|
||||
</div>{" "}
|
||||
{editingModel && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -361,170 +382,235 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
</div>
|
||||
<hr className="my-4 border-0 border-t border-solid border-border" />
|
||||
{/* Form body */}
|
||||
<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">
|
||||
The model identifier sent to the provider API.
|
||||
</p>
|
||||
<Input
|
||||
id={modelField.id}
|
||||
name={modelField.name}
|
||||
className={cn(
|
||||
"h-9 text-[13px] placeholder:text-content-disabled",
|
||||
modelField.error && "border-content-destructive",
|
||||
)}
|
||||
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
|
||||
}
|
||||
/>
|
||||
{modelField.error && (
|
||||
<p
|
||||
id={`${modelField.id}-error`}
|
||||
className="m-0 text-xs text-content-destructive"
|
||||
<form
|
||||
className="flex flex-1 flex-col"
|
||||
onSubmit={form.handleSubmit}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Model ID + Context Limit + Pricing */}
|
||||
<div className="space-y-4">
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
{" "}
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={modelField.id}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-content-primary"
|
||||
>
|
||||
{modelField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={contextLimitField.id}
|
||||
className="text-sm font-medium text-content-primary"
|
||||
>
|
||||
Context Limit{" "}
|
||||
<span className="text-xs font-bold text-content-destructive">
|
||||
*
|
||||
</span>
|
||||
</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",
|
||||
Model Identifier{" "}
|
||||
<span className="text-xs font-bold text-content-destructive">
|
||||
*
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="h-3 w-3 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
The model identifier sent to the provider API.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<Input
|
||||
id={modelField.id}
|
||||
name={modelField.name}
|
||||
className={cn(
|
||||
"h-9 text-[13px] placeholder:text-content-disabled",
|
||||
modelField.error && "border-content-destructive",
|
||||
)}
|
||||
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
|
||||
}
|
||||
/>
|
||||
{modelField.error && (
|
||||
<p
|
||||
id={`${modelField.id}-error`}
|
||||
className="m-0 text-xs text-content-destructive"
|
||||
>
|
||||
{modelField.helperText}
|
||||
</p>
|
||||
)}
|
||||
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 className="grid gap-1.5">
|
||||
<Label
|
||||
htmlFor={contextLimitField.id}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-content-primary"
|
||||
>
|
||||
Context Limit{" "}
|
||||
<span className="text-xs font-bold text-content-destructive">
|
||||
*
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="h-3 w-3 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
Max tokens in the context window.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"h-9",
|
||||
contextLimitField.error && "border-border-destructive",
|
||||
)}
|
||||
>
|
||||
<InputGroupInput
|
||||
id={contextLimitField.id}
|
||||
name={contextLimitField.name}
|
||||
className="h-9 min-w-0 text-[13px] placeholder:text-content-disabled"
|
||||
placeholder="200000"
|
||||
value={contextLimitField.value}
|
||||
onChange={contextLimitField.onChange}
|
||||
onBlur={contextLimitField.onBlur}
|
||||
disabled={isSaving}
|
||||
aria-invalid={contextLimitField.error}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="text-xs text-content-disabled">
|
||||
tokens
|
||||
</span>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>{" "}
|
||||
{contextLimitField.error && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{contextLimitField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider-specific model config fields */}
|
||||
<ModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Pricing - toggle */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPricing((v) => !v)}
|
||||
className="inline-flex cursor-pointer items-center gap-1 bg-transparent border-0 p-0 text-sm font-medium text-content-secondary transition-colors hover:text-content-primary"
|
||||
>
|
||||
{showPricing ? (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
)}
|
||||
Pricing
|
||||
</button>
|
||||
{showPricing && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Optional USD pricing metadata per 1M tokens. Leave any
|
||||
field blank to keep pricing unset and use provider or
|
||||
profile defaults when available.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<PricingModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Usage Tracking */}
|
||||
<div className="border-0 border-t border-solid border-border pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPricing((v) => !v)}
|
||||
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
|
||||
>
|
||||
<div>
|
||||
<h3 className="m-0 text-sm font-medium text-content-primary">
|
||||
Cost Tracking{" "}
|
||||
</h3>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Set per-token pricing so Coder can track costs and enforce
|
||||
spending limits.
|
||||
</p>
|
||||
</div>
|
||||
{showPricing ? (
|
||||
<ChevronDownIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{showPricing && (
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 sm:grid-cols-4">
|
||||
<PricingModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced - toggle */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="inline-flex cursor-pointer items-center gap-1 bg-transparent border-0 p-0 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
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-4 space-y-5">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<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
|
||||
{/* Provider Configuration */}
|
||||
<div className="border-0 border-t border-solid border-border pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowProviderConfig((v) => !v)}
|
||||
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
|
||||
>
|
||||
<div>
|
||||
<h3 className="m-0 text-sm font-medium text-content-primary">
|
||||
Provider Configuration
|
||||
</h3>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Tune provider-specific behavior like reasoning, tool calling,
|
||||
and web search.
|
||||
</p>
|
||||
</div>
|
||||
{showProviderConfig ? (
|
||||
<ChevronDownIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
|
||||
)}
|
||||
</button>
|
||||
{showProviderConfig && (
|
||||
<div className="pt-3">
|
||||
<ModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced */}
|
||||
<div className="border-0 border-t border-solid border-border pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="flex w-full cursor-pointer items-start justify-between border-0 bg-transparent p-0 text-left transition-colors hover:text-content-primary"
|
||||
>
|
||||
<div>
|
||||
<h3 className="m-0 text-sm font-medium text-content-primary">
|
||||
Advanced
|
||||
</h3>
|
||||
<p className="m-0 text-xs text-content-secondary">
|
||||
Low-level parameters like temperature and penalties. Rarely
|
||||
need changing.
|
||||
</p>
|
||||
</div>
|
||||
{showAdvanced ? (
|
||||
<ChevronDownIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
|
||||
) : (
|
||||
<ChevronRightIcon className="mt-0.5 h-4 w-4 shrink-0 text-content-secondary" />
|
||||
)}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="grid grid-cols-2 gap-3 pt-3 sm:grid-cols-3">
|
||||
<GeneralModelConfigFields
|
||||
provider={selectedProviderState.provider}
|
||||
form={form}
|
||||
fieldErrors={modelConfigFormBuildResult.fieldErrors}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
<Label
|
||||
htmlFor={compressionThresholdField.id}
|
||||
className="inline-flex items-center gap-1 text-[13px] font-medium text-content-primary"
|
||||
>
|
||||
Compression Threshold
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="h-3 w-3 text-content-secondary" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
Percentage at which context is compressed.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Label>
|
||||
<InputGroup
|
||||
className={cn(
|
||||
"h-9",
|
||||
compressionThresholdField.error &&
|
||||
"border-border-destructive",
|
||||
)}
|
||||
>
|
||||
<InputGroupInput
|
||||
id={compressionThresholdField.id}
|
||||
name={compressionThresholdField.name}
|
||||
className={cn(
|
||||
"h-9 text-[13px] placeholder:text-content-disabled",
|
||||
compressionThresholdField.error &&
|
||||
"border-content-destructive",
|
||||
)}
|
||||
className="h-9 text-[13px] placeholder:text-content-disabled"
|
||||
placeholder="70"
|
||||
value={compressionThresholdField.value}
|
||||
onChange={compressionThresholdField.onChange}
|
||||
@@ -532,18 +618,20 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
disabled={isSaving}
|
||||
aria-invalid={compressionThresholdField.error}
|
||||
/>
|
||||
{compressionThresholdField.error && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{compressionThresholdField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<span className="text-xs text-content-disabled">%</span>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{compressionThresholdField.error && (
|
||||
<p className="m-0 text-xs text-content-destructive">
|
||||
{compressionThresholdField.helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Footer - pushed to bottom */}
|
||||
<div className="mt-auto py-6">
|
||||
<hr className="mb-4 border-0 border-t border-solid border-border" />
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
Reference in New Issue
Block a user