mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-09 04:26:43 +02:00
initial budget ui on the provider
This commit is contained in:
+18
-1
@@ -1653,6 +1653,23 @@
|
|||||||
"aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider",
|
"aiProviderNetworkSettingsDescription": "Choose how traffic reaches this provider",
|
||||||
"aiProviderAuthSettings": "Authentication",
|
"aiProviderAuthSettings": "Authentication",
|
||||||
"aiProviderAuthSettingsDescription": "Configure how this provider authenticates requests to its upstream URL",
|
"aiProviderAuthSettingsDescription": "Configure how this provider authenticates requests to its upstream URL",
|
||||||
|
"aiProviderBudgetSettings": "Budget",
|
||||||
|
"aiProviderBudgetSettingsDescription": "Configure how this provider restricts usage based on spending or token limits",
|
||||||
|
"aiBudgetAdd": "Add Budget",
|
||||||
|
"aiBudgetEmpty": "No budgets configured yet. Click Add Budget to set a spending or token limit.",
|
||||||
|
"aiBudgetAmountPlaceholder": "Maximum spend",
|
||||||
|
"aiBudgetPeriodHourly": "Hourly",
|
||||||
|
"aiBudgetPeriodDaily": "Daily",
|
||||||
|
"aiBudgetPeriodWeekly": "Weekly",
|
||||||
|
"aiBudgetPeriodMonthly": "Monthly",
|
||||||
|
"aiBudgetPeriodYearly": "Yearly",
|
||||||
|
"aiBudgetPeriodLifetime": "Lifetime",
|
||||||
|
"aiBudgetUnitUsd": "USD",
|
||||||
|
"aiBudgetUnitTokens": "Tokens",
|
||||||
|
"aiBudgetConflictError": "A budget for this reset period and spend type already exists",
|
||||||
|
"aiBudgetInvalidAmountError": "Enter a maximum spend greater than 0",
|
||||||
|
"aiBudgetUpdated": "Budgets updated",
|
||||||
|
"aiBudgetErrorSave": "Failed to update budgets",
|
||||||
"aiProviderType": "Provider Type",
|
"aiProviderType": "Provider Type",
|
||||||
"aiProviderTypeSearch": "Search providers...",
|
"aiProviderTypeSearch": "Search providers...",
|
||||||
"aiProviderTypeNotFound": "No provider type found",
|
"aiProviderTypeNotFound": "No provider type found",
|
||||||
@@ -4004,4 +4021,4 @@
|
|||||||
"sessionToolbarShow": "Show toolbar",
|
"sessionToolbarShow": "Show toolbar",
|
||||||
"sessionToolbarHide": "Hide toolbar",
|
"sessionToolbarHide": "Hide toolbar",
|
||||||
"actionUpdateSiteApprovals": "Update Site Approvals"
|
"actionUpdateSiteApprovals": "Update Site Approvals"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# AI Budget API
|
||||||
|
|
||||||
|
Public/OSS CRUD entity (`server/routers/aiBudget/`, not enterprise-gated).
|
||||||
|
Table: `aiBudgets` in `server/db/{pg,sqlite}/schema/schema.ts`, type `AiBudget`.
|
||||||
|
|
||||||
|
## What a budget is
|
||||||
|
|
||||||
|
A row is a spend/usage cap of `amount` `unit` (`usd` | `tokens`) per `period`
|
||||||
|
(`hourly` | `daily` | `weekly` | `monthly` | `yearly` | `lifetime`), with
|
||||||
|
`enforcement` (`hard` | `soft`) and an `enabled` flag.
|
||||||
|
|
||||||
|
Every budget belongs to an org (`orgId`, required) and is optionally further
|
||||||
|
scoped to **exactly one** of:
|
||||||
|
|
||||||
|
- `providerId` → an `aiProviders` row
|
||||||
|
- `modelId` → an `aiModels` row
|
||||||
|
- `resourceId` → a `resources` row
|
||||||
|
- `siteResourceId` → a `siteResources` row
|
||||||
|
- `roleId` → a `roles` row
|
||||||
|
|
||||||
|
If none of those five are set, the budget is **org-wide**. Setting more than
|
||||||
|
one at once is rejected by `validation.ts`'s `refineBudgetScopeFields`
|
||||||
|
(`400`, "Only one of providerId, modelId, resourceId, siteResourceId, or
|
||||||
|
roleId may be set on a budget").
|
||||||
|
|
||||||
|
## Uniqueness / conflict rule
|
||||||
|
|
||||||
|
A given scope (one specific provider, or model, or resource, or site
|
||||||
|
resource, or role, or "org-wide") may have **multiple** budgets, but at most
|
||||||
|
**one per `(unit, period)` combination** — e.g. one `weekly`/`usd` budget and
|
||||||
|
one `hourly`/`usd` budget can coexist on the same provider, but two
|
||||||
|
`weekly`/`usd` budgets cannot. This is enforced at two levels:
|
||||||
|
|
||||||
|
- DB: composite `unique` constraints in both schema files —
|
||||||
|
`ai_budget_provider_uniq (providerId, unit, period)`,
|
||||||
|
`ai_budget_model_uniq (modelId, unit, period)`,
|
||||||
|
`ai_budget_resource_uniq (resourceId, unit, period)`,
|
||||||
|
`ai_budget_site_resource_uniq (siteResourceId, unit, period)`,
|
||||||
|
`ai_budget_role_uniq (roleId, unit, period)`. (NULL scope columns never
|
||||||
|
collide under a plain unique index, so this does *not* cover the org-wide
|
||||||
|
case — see next bullet.)
|
||||||
|
- App: `createAiBudget`/`updateAiBudget` both run an explicit pre-check
|
||||||
|
query keyed on `(scopeCondition, unit, period)` before insert/update,
|
||||||
|
where `scopeCondition` is `eq(<scopeColumn>, id)` for whichever scope
|
||||||
|
field is set, or — when none is set — `orgId = X AND` all five scope
|
||||||
|
columns `IS NULL`, so org-wide budgets get the same one-per-`(unit,
|
||||||
|
period)` guarantee even though the DB constraint can't express it.
|
||||||
|
Violating this returns `409` with
|
||||||
|
`` `A ${period} ${unit} budget already exists for this scope` ``.
|
||||||
|
|
||||||
|
Because only one row can ever exist for a given `(scope, unit, period)`,
|
||||||
|
there is no separate check needed to prevent a `hard` and a `soft` budget
|
||||||
|
from coexisting on the same `(scope, unit, period)` — the conflict check
|
||||||
|
above already blocks the second row regardless of its `enforcement` value.
|
||||||
|
|
||||||
|
On `updateAiBudget`, the conflict/ownership checks are run against the
|
||||||
|
**merged** next-state (existing row's scope/unit/period overlaid with
|
||||||
|
whatever the request body changes), not just the fields present in the
|
||||||
|
body — so e.g. changing only `unit` on a budget that already has
|
||||||
|
`providerId` set re-validates against that provider's other budgets at the
|
||||||
|
new unit.
|
||||||
|
|
||||||
|
## Ownership validation
|
||||||
|
|
||||||
|
`providerId`/`modelId`/`resourceId`/`siteResourceId`/`roleId` are validated
|
||||||
|
to belong to the same `orgId` as the budget (`modelId` via an
|
||||||
|
`aiModels ⋈ aiProviders` join, since `aiModels` has no `orgId` column
|
||||||
|
directly). A mismatch returns `404`, not `403` — this matches how the
|
||||||
|
sibling `aiProvider`/`aiModel` routers report cross-org references.
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
All under `server/routers/external.ts`, registered right after the
|
||||||
|
`aiProvider`/`aiModel` block. `PUT` = create, `POST` = update (repo
|
||||||
|
convention, not standard REST).
|
||||||
|
|
||||||
|
| Method | Path | Middleware | Action | Handler |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| PUT | `/org/:orgId/ai-budget` | `verifyOrgAccess` | `createAiBudget` | `createAiBudget` |
|
||||||
|
| GET | `/org/:orgId/ai-budgets` | `verifyOrgAccess` | `listAiBudgets` | `listAiBudgets` (paginated) |
|
||||||
|
| GET | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `getAiBudget` | `getAiBudget` |
|
||||||
|
| POST | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `updateAiBudget` | `updateAiBudget` |
|
||||||
|
| DELETE | `/ai-budget/:budgetId` | `verifyAiBudgetAccess` | `deleteAiBudget` | `deleteAiBudget` |
|
||||||
|
| GET | `/ai-provider/:providerId/ai-budgets` | `verifyAiProviderAccess` | `listAiBudgets` | `listAiBudgetsForProvider` |
|
||||||
|
| GET | `/ai-model/:modelId/ai-budgets` | `verifyAiModelAccess` | `listAiBudgets` | `listAiBudgetsForModel` |
|
||||||
|
| GET | `/resource/:resourceId/ai-budgets` | `verifyResourceAccess` | `listAiBudgets` | `listAiBudgetsForResource` |
|
||||||
|
| GET | `/site-resource/:siteResourceId/ai-budgets` | `verifySiteResourceAccess` | `listAiBudgets` | `listAiBudgetsForSiteResource` |
|
||||||
|
| GET | `/role/:roleId/ai-budgets` | `verifyRoleAccess` | `listAiBudgets` | `listAiBudgetsForRole` |
|
||||||
|
|
||||||
|
The five scope-filtered `GET .../ai-budgets` routes intentionally reuse the
|
||||||
|
single `ActionsEnum.listAiBudgets` action rather than getting one action
|
||||||
|
each — access control is already fully handled by the entity-specific
|
||||||
|
middleware (a user who can see the provider/resource/etc. can see its
|
||||||
|
budgets), so per-scope actions would just be enum bloat. They also skip
|
||||||
|
pagination (unlike the org-wide list) since a single entity realistically
|
||||||
|
has only a handful of `(unit, period)` budgets — response shape is a flat
|
||||||
|
`{ budgets: AiBudget[] }` (`ListAiBudgetsByScopeResponse`), not
|
||||||
|
`PaginatedResponse`.
|
||||||
|
|
||||||
|
`verifyAiBudgetAccess` (`server/middlewares/verifyAiBudgetAccess.ts`) loads
|
||||||
|
the budget by `budgetId`, resolves its `orgId` directly off the row (no
|
||||||
|
join needed, unlike `verifyAiModelAccess`), and stashes it on
|
||||||
|
`req.aiBudget` so `getAiBudget`/`updateAiBudget` can skip a re-fetch.
|
||||||
|
|
||||||
|
## Request/response shapes
|
||||||
|
|
||||||
|
- Create body: `providerId?`, `modelId?`, `resourceId?`, `siteResourceId?`,
|
||||||
|
`roleId?` (all `number`, mutually exclusive), `amount` (positive
|
||||||
|
`number`, required), `unit` (required), `period` (default `"monthly"`),
|
||||||
|
`enforcement` (default `"hard"`), `enabled?` (default `true`).
|
||||||
|
- Update body: same fields, all optional; the five scope fields are
|
||||||
|
`nullable().optional()` so a client can explicitly send `null` to clear
|
||||||
|
a scope (turning a scoped budget into an org-wide one).
|
||||||
|
- All five CRUD responses wrap a single `budget: AiBudget` (or
|
||||||
|
`budgets: AiBudget[]` + `pagination` for the org-wide list). No public/
|
||||||
|
private mapper exists for `AiBudget` — unlike `AiProvider`, there's no
|
||||||
|
secret field to strip, so the raw DB row is returned as-is.
|
||||||
|
|
||||||
|
## Not yet migrated
|
||||||
|
|
||||||
|
Schema changes here (composite unique constraints) were made directly in
|
||||||
|
`schema.ts` without hand-writing a `server/migrations/*.sql` file — this
|
||||||
|
repo's CI (`.github/workflows/test.yml`) runs `drizzle-kit generate`
|
||||||
|
against `schema.ts` fresh, and other recent schema-only commits (e.g. "Remove
|
||||||
|
budget periods") follow the same pattern of not committing a matching
|
||||||
|
migration by hand.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SettingsContainer } from "@app/components/Settings";
|
||||||
|
import { BudgetsEditor } from "@app/components/BudgetsEditor";
|
||||||
|
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
|
export default function AiProviderBudgetPage() {
|
||||||
|
const { provider } = useAiProviderContext();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContainer>
|
||||||
|
<BudgetsEditor
|
||||||
|
orgId={provider.orgId}
|
||||||
|
scope={{ type: "provider", id: provider.providerId }}
|
||||||
|
title={t("aiProviderBudgetSettings")}
|
||||||
|
description={t("aiProviderBudgetSettingsDescription")}
|
||||||
|
/>
|
||||||
|
</SettingsContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,6 +76,10 @@ export default async function AiProviderLayout({ children, params }: Props) {
|
|||||||
{
|
{
|
||||||
title: t("aiProviderAuthSettings"),
|
title: t("aiProviderAuthSettings"),
|
||||||
href: "/{orgId}/settings/ai-providers/{providerId}/authentication"
|
href: "/{orgId}/settings/ai-providers/{providerId}/authentication"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("aiProviderBudgetSettings"),
|
||||||
|
href: "/{orgId}/settings/ai-providers/{providerId}/budget"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionBody,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionFooter,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionTitle
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue
|
||||||
|
} from "@app/components/ui/select";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
|
import {
|
||||||
|
AI_BUDGET_PERIODS,
|
||||||
|
AI_BUDGET_UNITS,
|
||||||
|
getAiBudgetScopeBodyField,
|
||||||
|
type AiBudgetPeriod,
|
||||||
|
type AiBudgetScope,
|
||||||
|
type AiBudgetUnit
|
||||||
|
} from "@app/lib/aiBudgetScope";
|
||||||
|
import { aiBudgetQueries } from "@app/lib/queries";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AiBudget } from "@server/db";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type BudgetRow = {
|
||||||
|
key: string;
|
||||||
|
budgetId?: number;
|
||||||
|
amount: string;
|
||||||
|
unit: AiBudgetUnit;
|
||||||
|
period: AiBudgetPeriod;
|
||||||
|
};
|
||||||
|
|
||||||
|
function rowsFromBudgets(budgets: AiBudget[]): BudgetRow[] {
|
||||||
|
return budgets.map((budget) => ({
|
||||||
|
key: String(budget.budgetId),
|
||||||
|
budgetId: budget.budgetId,
|
||||||
|
amount: String(budget.amount),
|
||||||
|
unit: budget.unit,
|
||||||
|
period: budget.period
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function comboKey(unit: AiBudgetUnit, period: AiBudgetPeriod): string {
|
||||||
|
return `${unit}:${period}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextAvailableCombo(rows: BudgetRow[]): {
|
||||||
|
unit: AiBudgetUnit;
|
||||||
|
period: AiBudgetPeriod;
|
||||||
|
} {
|
||||||
|
const used = new Set(rows.map((row) => comboKey(row.unit, row.period)));
|
||||||
|
for (const unit of AI_BUDGET_UNITS) {
|
||||||
|
for (const period of AI_BUDGET_PERIODS) {
|
||||||
|
if (!used.has(comboKey(unit, period))) {
|
||||||
|
return { unit, period };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { unit: "usd", period: "monthly" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BudgetsEditor({
|
||||||
|
scope,
|
||||||
|
orgId,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
}: {
|
||||||
|
scope: AiBudgetScope;
|
||||||
|
orgId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}) {
|
||||||
|
const { env } = useEnvContext();
|
||||||
|
const api = createApiClient({ env });
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const t = useTranslations();
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<BudgetRow[]>([]);
|
||||||
|
const [saveLoading, setSaveLoading] = useState(false);
|
||||||
|
const [attemptedSave, setAttemptedSave] = useState(false);
|
||||||
|
|
||||||
|
const budgetsQuery = useQuery(aiBudgetQueries.scoped({ scope }));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!budgetsQuery.data) return;
|
||||||
|
setRows(rowsFromBudgets(budgetsQuery.data));
|
||||||
|
setAttemptedSave(false);
|
||||||
|
}, [budgetsQuery.data]);
|
||||||
|
|
||||||
|
const conflictingKeys = useMemo(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = comboKey(row.unit, row.period);
|
||||||
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const conflicting = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = comboKey(row.unit, row.period);
|
||||||
|
if ((counts.get(key) ?? 0) > 1) {
|
||||||
|
conflicting.add(row.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return conflicting;
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const invalidAmountKeys = useMemo(() => {
|
||||||
|
const invalid = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const amount = Number(row.amount);
|
||||||
|
if (!row.amount.trim() || !Number.isFinite(amount) || amount <= 0) {
|
||||||
|
invalid.add(row.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return invalid;
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const hasErrors = conflictingKeys.size > 0 || invalidAmountKeys.size > 0;
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
const combo = nextAvailableCombo(rows);
|
||||||
|
setRows((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
key: crypto.randomUUID(),
|
||||||
|
amount: "",
|
||||||
|
unit: combo.unit,
|
||||||
|
period: combo.period
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(key: string) {
|
||||||
|
setRows((prev) => prev.filter((row) => row.key !== key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRow(key: string, patch: Partial<BudgetRow>) {
|
||||||
|
setRows((prev) =>
|
||||||
|
prev.map((row) => (row.key === key ? { ...row, ...patch } : row))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
setAttemptedSave(true);
|
||||||
|
if (hasErrors) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("aiBudgetErrorSave"),
|
||||||
|
description: conflictingKeys.size
|
||||||
|
? t("aiBudgetConflictError")
|
||||||
|
: t("aiBudgetInvalidAmountError")
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveLoading(true);
|
||||||
|
try {
|
||||||
|
const existing = budgetsQuery.data ?? [];
|
||||||
|
const existingById = new Map(
|
||||||
|
existing.map((budget) => [budget.budgetId, budget])
|
||||||
|
);
|
||||||
|
const currentBudgetIds = new Set(
|
||||||
|
rows
|
||||||
|
.filter((row) => row.budgetId !== undefined)
|
||||||
|
.map((row) => row.budgetId as number)
|
||||||
|
);
|
||||||
|
const bodyField = getAiBudgetScopeBodyField(scope);
|
||||||
|
|
||||||
|
const toDelete = existing.filter(
|
||||||
|
(budget) => !currentBudgetIds.has(budget.budgetId)
|
||||||
|
);
|
||||||
|
const toCreate = rows.filter((row) => row.budgetId === undefined);
|
||||||
|
const toUpdate = rows.filter((row) => {
|
||||||
|
if (row.budgetId === undefined) return false;
|
||||||
|
const existingBudget = existingById.get(row.budgetId);
|
||||||
|
if (!existingBudget) return false;
|
||||||
|
return (
|
||||||
|
existingBudget.amount !== Number(row.amount) ||
|
||||||
|
existingBudget.unit !== row.unit ||
|
||||||
|
existingBudget.period !== row.period
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...toDelete.map((budget) =>
|
||||||
|
api.delete(`/ai-budget/${budget.budgetId}`)
|
||||||
|
),
|
||||||
|
...toCreate.map((row) =>
|
||||||
|
api.put(`/org/${orgId}/ai-budget`, {
|
||||||
|
[bodyField]: scope.id,
|
||||||
|
amount: Number(row.amount),
|
||||||
|
unit: row.unit,
|
||||||
|
period: row.period
|
||||||
|
})
|
||||||
|
),
|
||||||
|
...toUpdate.map((row) =>
|
||||||
|
api.post(`/ai-budget/${row.budgetId}`, {
|
||||||
|
amount: Number(row.amount),
|
||||||
|
unit: row.unit,
|
||||||
|
period: row.period
|
||||||
|
})
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries(
|
||||||
|
aiBudgetQueries.scoped({ scope })
|
||||||
|
);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: t("success"),
|
||||||
|
description: t("aiBudgetUpdated")
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("aiBudgetErrorSave"),
|
||||||
|
description: formatAxiosError(e, t("aiBudgetErrorSave"))
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaveLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodLabels: Record<AiBudgetPeriod, string> = {
|
||||||
|
hourly: t("aiBudgetPeriodHourly"),
|
||||||
|
daily: t("aiBudgetPeriodDaily"),
|
||||||
|
weekly: t("aiBudgetPeriodWeekly"),
|
||||||
|
monthly: t("aiBudgetPeriodMonthly"),
|
||||||
|
yearly: t("aiBudgetPeriodYearly"),
|
||||||
|
lifetime: t("aiBudgetPeriodLifetime")
|
||||||
|
};
|
||||||
|
|
||||||
|
const unitLabels: Record<AiBudgetUnit, string> = {
|
||||||
|
usd: t("aiBudgetUnitUsd"),
|
||||||
|
tokens: t("aiBudgetUnitTokens")
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<SettingsSectionTitle>{title}</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{description}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<SettingsSectionBody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("aiBudgetEmpty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{rows.map((row) => {
|
||||||
|
const showConflict = conflictingKeys.has(row.key);
|
||||||
|
const showInvalidAmount =
|
||||||
|
attemptedSave &&
|
||||||
|
invalidAmountKeys.has(row.key);
|
||||||
|
return (
|
||||||
|
<div key={row.key} className="space-y-1">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<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
|
||||||
|
value={row.period}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateRow(row.key, {
|
||||||
|
period: value as AiBudgetPeriod
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
saveLoading ||
|
||||||
|
budgetsQuery.isLoading
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-44 shrink-0"
|
||||||
|
aria-invalid={showConflict}
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AI_BUDGET_PERIODS.map(
|
||||||
|
(period) => (
|
||||||
|
<SelectItem
|
||||||
|
key={period}
|
||||||
|
value={period}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
periodLabels[
|
||||||
|
period
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={row.unit}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateRow(row.key, {
|
||||||
|
unit: value as AiBudgetUnit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
saveLoading ||
|
||||||
|
budgetsQuery.isLoading
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-32 shrink-0"
|
||||||
|
aria-invalid={showConflict}
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AI_BUDGET_UNITS.map(
|
||||||
|
(unit) => (
|
||||||
|
<SelectItem
|
||||||
|
key={unit}
|
||||||
|
value={unit}
|
||||||
|
>
|
||||||
|
{unitLabels[unit]}
|
||||||
|
</SelectItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0"
|
||||||
|
disabled={
|
||||||
|
saveLoading ||
|
||||||
|
budgetsQuery.isLoading
|
||||||
|
}
|
||||||
|
onClick={() => removeRow(row.key)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{showConflict && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t("aiBudgetConflictError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{showInvalidAmount && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{t("aiBudgetInvalidAmountError")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SettingsSectionBody>
|
||||||
|
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={saveLoading}
|
||||||
|
disabled={saveLoading || budgetsQuery.isLoading}
|
||||||
|
onClick={onSave}
|
||||||
|
>
|
||||||
|
{t("saveSettings")}
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { AiBudget } from "@server/db";
|
||||||
|
|
||||||
|
export type AiBudgetScopeType =
|
||||||
|
| "provider"
|
||||||
|
| "model"
|
||||||
|
| "resource"
|
||||||
|
| "siteResource"
|
||||||
|
| "role";
|
||||||
|
|
||||||
|
export type AiBudgetScope = {
|
||||||
|
type: AiBudgetScopeType;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AiBudgetScopeBodyField =
|
||||||
|
| "providerId"
|
||||||
|
| "modelId"
|
||||||
|
| "resourceId"
|
||||||
|
| "siteResourceId"
|
||||||
|
| "roleId";
|
||||||
|
|
||||||
|
const scopeConfig: Record<
|
||||||
|
AiBudgetScopeType,
|
||||||
|
{ listPath: (id: number) => string; bodyField: AiBudgetScopeBodyField }
|
||||||
|
> = {
|
||||||
|
provider: {
|
||||||
|
listPath: (id) => `/ai-provider/${id}/ai-budgets`,
|
||||||
|
bodyField: "providerId"
|
||||||
|
},
|
||||||
|
model: {
|
||||||
|
listPath: (id) => `/ai-model/${id}/ai-budgets`,
|
||||||
|
bodyField: "modelId"
|
||||||
|
},
|
||||||
|
resource: {
|
||||||
|
listPath: (id) => `/resource/${id}/ai-budgets`,
|
||||||
|
bodyField: "resourceId"
|
||||||
|
},
|
||||||
|
siteResource: {
|
||||||
|
listPath: (id) => `/site-resource/${id}/ai-budgets`,
|
||||||
|
bodyField: "siteResourceId"
|
||||||
|
},
|
||||||
|
role: {
|
||||||
|
listPath: (id) => `/role/${id}/ai-budgets`,
|
||||||
|
bodyField: "roleId"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAiBudgetScopeListPath(scope: AiBudgetScope): string {
|
||||||
|
return scopeConfig[scope.type].listPath(scope.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAiBudgetScopeBodyField(
|
||||||
|
scope: AiBudgetScope
|
||||||
|
): AiBudgetScopeBodyField {
|
||||||
|
return scopeConfig[scope.type].bodyField;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AiBudgetUnit = AiBudget["unit"];
|
||||||
|
export type AiBudgetPeriod = AiBudget["period"];
|
||||||
|
|
||||||
|
export const AI_BUDGET_UNITS: AiBudgetUnit[] = ["usd", "tokens"];
|
||||||
|
|
||||||
|
export const AI_BUDGET_PERIODS: AiBudgetPeriod[] = [
|
||||||
|
"hourly",
|
||||||
|
"daily",
|
||||||
|
"weekly",
|
||||||
|
"monthly",
|
||||||
|
"yearly",
|
||||||
|
"lifetime"
|
||||||
|
];
|
||||||
@@ -63,6 +63,11 @@ import type {
|
|||||||
ListAiModelsResponse,
|
ListAiModelsResponse,
|
||||||
ListAiProvidersResponse
|
ListAiProvidersResponse
|
||||||
} from "@server/routers/aiProvider/types";
|
} from "@server/routers/aiProvider/types";
|
||||||
|
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
|
||||||
|
import {
|
||||||
|
getAiBudgetScopeListPath,
|
||||||
|
type AiBudgetScope
|
||||||
|
} from "@app/lib/aiBudgetScope";
|
||||||
import type { ListUsersResponse } from "@server/routers/user";
|
import type { ListUsersResponse } from "@server/routers/user";
|
||||||
import type ResponseT from "@server/types/Response";
|
import type ResponseT from "@server/types/Response";
|
||||||
import {
|
import {
|
||||||
@@ -1213,6 +1218,19 @@ export const aiProviderQueries = {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const aiBudgetQueries = {
|
||||||
|
scoped: ({ scope }: { scope: AiBudgetScope }) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ["AI_BUDGETS", scope.type, scope.id] as const,
|
||||||
|
queryFn: async ({ signal, meta }) => {
|
||||||
|
const res = await meta!.api.get<
|
||||||
|
AxiosResponse<ListAiBudgetsByScopeResponse>
|
||||||
|
>(getAiBudgetScopeListPath(scope), { signal });
|
||||||
|
return res.data.data.budgets;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
export const resourceQueries = {
|
export const resourceQueries = {
|
||||||
resourceUsers: ({ resourceId }: { resourceId: number }) =>
|
resourceUsers: ({ resourceId }: { resourceId: number }) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
|
|||||||
Reference in New Issue
Block a user