make the budget match the rules

This commit is contained in:
Owen
2026-08-10 12:20:47 -04:00
parent 211d3a53f5
commit 7e6e0a8a9c
2 changed files with 242 additions and 148 deletions
+3
View File
@@ -1657,6 +1657,9 @@
"aiProviderBudgetSettingsDescription": "Configure how this provider restricts usage based on spending or token limits", "aiProviderBudgetSettingsDescription": "Configure how this provider restricts usage based on spending or token limits",
"aiBudgetAdd": "Add Budget", "aiBudgetAdd": "Add Budget",
"aiBudgetEmpty": "No budgets configured yet. Click Add Budget to set a spending or token limit.", "aiBudgetEmpty": "No budgets configured yet. Click Add Budget to set a spending or token limit.",
"aiBudgetUnit": "Spend Type",
"aiBudgetPeriod": "Reset Period",
"aiBudgetAmount": "Maximum Spend",
"aiBudgetAmountPlaceholder": "Maximum spend", "aiBudgetAmountPlaceholder": "Maximum spend",
"aiBudgetPeriodHourly": "Hourly", "aiBudgetPeriodHourly": "Hourly",
"aiBudgetPeriodDaily": "Daily", "aiBudgetPeriodDaily": "Daily",
+186 -95
View File
@@ -1,14 +1,18 @@
"use client"; "use client";
import { import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection, SettingsSection,
SettingsSectionBody, SettingsSectionBody,
SettingsSectionDescription, SettingsSectionDescription,
SettingsSectionFooter, SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader, SettingsSectionHeader,
SettingsSectionTitle SettingsSectionTitle
} from "@app/components/Settings"; } from "@app/components/Settings";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { import {
Select, Select,
@@ -17,6 +21,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
@@ -247,71 +259,80 @@ export function BudgetsEditor({
tokens: t("aiBudgetUnitTokens") tokens: t("aiBudgetUnitTokens")
}; };
const addRowButton = (
<Button
type="button"
variant="outline"
onClick={addRow}
disabled={saveLoading || budgetsQuery.isLoading}
>
<Plus className="h-4 w-4 mr-2" />
{t("aiBudgetAdd")}
</Button>
);
return ( return (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<SettingsSectionTitle>{title}</SettingsSectionTitle> <SettingsSectionTitle>{title}</SettingsSectionTitle>
<SettingsSectionDescription> <SettingsSectionDescription>
{description} {description}
</SettingsSectionDescription> </SettingsSectionDescription>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={addRow}
disabled={budgetsQuery.isLoading}
className="shrink-0"
>
<Plus className="h-4 w-4 mr-1" />
{t("aiBudgetAdd")}
</Button>
</div>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm>
<SettingsFormGrid>
<SettingsFormCell span="half">
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("aiBudgetUnit")}
</TableHead>
<TableHead>
{t("aiBudgetPeriod")}
</TableHead>
<TableHead>
{t("aiBudgetAmount")}
</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? ( {rows.length === 0 ? (
<p className="text-sm text-muted-foreground"> <DataTableEmptyState
{t("aiBudgetEmpty")} colSpan={4}
</p> message={t("aiBudgetEmpty")}
action={addRowButton}
compact
/>
) : ( ) : (
<div className="space-y-3"> rows.map((row) => {
{rows.map((row) => { const showConflict =
const showConflict = conflictingKeys.has(row.key); conflictingKeys.has(
row.key
);
const showInvalidAmount = const showInvalidAmount =
attemptedSave && attemptedSave &&
invalidAmountKeys.has(row.key); invalidAmountKeys.has(
row.key
);
return ( return (
<div key={row.key} className="space-y-1"> <TableRow key={row.key}>
<div className="flex items-start gap-2"> <TableCell>
<Input
type="number"
min="0"
step="any"
placeholder={t(
"aiBudgetAmountPlaceholder"
)}
value={row.amount}
aria-invalid={showInvalidAmount}
disabled={
saveLoading ||
budgetsQuery.isLoading
}
onChange={(e) =>
updateRow(row.key, {
amount: e.target.value
})
}
className="flex-1"
/>
<Select <Select
value={row.period} value={row.unit}
onValueChange={(value) => onValueChange={(
updateRow(row.key, { value
period: value as AiBudgetPeriod ) =>
}) updateRow(
row.key,
{
unit: value as AiBudgetUnit
}
)
} }
disabled={ disabled={
saveLoading || saveLoading ||
@@ -319,17 +340,77 @@ export function BudgetsEditor({
} }
> >
<SelectTrigger <SelectTrigger
className="w-44 shrink-0" className="w-full min-w-0"
aria-invalid={showConflict} aria-invalid={
showConflict
}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{AI_BUDGET_UNITS.map(
(
unit
) => (
<SelectItem
key={
unit
}
value={
unit
}
>
{
unitLabels[
unit
]
}
</SelectItem>
)
)}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<Select
value={
row.period
}
onValueChange={(
value
) =>
updateRow(
row.key,
{
period: value as AiBudgetPeriod
}
)
}
disabled={
saveLoading ||
budgetsQuery.isLoading
}
>
<SelectTrigger
className="w-full min-w-0"
aria-invalid={
showConflict
}
> >
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{AI_BUDGET_PERIODS.map( {AI_BUDGET_PERIODS.map(
(period) => ( (
period
) => (
<SelectItem <SelectItem
key={period} key={
value={period} period
}
value={
period
}
> >
{ {
periodLabels[ periodLabels[
@@ -341,66 +422,76 @@ export function BudgetsEditor({
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
<Select </TableCell>
value={row.unit} <TableCell>
onValueChange={(value) => <Input
updateRow(row.key, { type="number"
unit: value as AiBudgetUnit min="0"
}) step="any"
placeholder={t(
"aiBudgetAmountPlaceholder"
)}
value={
row.amount
}
aria-invalid={
showInvalidAmount
} }
disabled={ disabled={
saveLoading || saveLoading ||
budgetsQuery.isLoading budgetsQuery.isLoading
} }
> onChange={(e) =>
<SelectTrigger updateRow(
className="w-32 shrink-0" row.key,
aria-invalid={showConflict} {
> amount: e
<SelectValue /> .target
</SelectTrigger> .value
<SelectContent> }
{AI_BUDGET_UNITS.map(
(unit) => (
<SelectItem
key={unit}
value={unit}
>
{unitLabels[unit]}
</SelectItem>
) )
)} }
</SelectContent> className="w-full min-w-0"
</Select> />
</TableCell>
<TableCell>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="shrink-0"
disabled={ disabled={
saveLoading || saveLoading ||
budgetsQuery.isLoading budgetsQuery.isLoading
} }
onClick={() => removeRow(row.key)} onClick={() =>
removeRow(
row.key
)
}
> >
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>
</div> </TableCell>
{showConflict && ( </TableRow>
<p className="text-xs text-destructive">
{t("aiBudgetConflictError")}
</p>
)}
{showInvalidAmount && (
<p className="text-xs text-destructive">
{t("aiBudgetInvalidAmountError")}
</p>
)}
</div>
); );
})} })
</div>
)} )}
</TableBody>
</Table>
{(conflictingKeys.size > 0 ||
(attemptedSave &&
invalidAmountKeys.size > 0)) && (
<p className="text-xs text-destructive">
{conflictingKeys.size > 0
? t("aiBudgetConflictError")
: t("aiBudgetInvalidAmountError")}
</p>
)}
{rows.length > 0 && addRowButton}
</div>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
<SettingsSectionFooter> <SettingsSectionFooter>