From f7d2a3518434bc1038714e445f5ee3f76493ade5 Mon Sep 17 00:00:00 2001 From: Henrik-3 Date: Sun, 5 Jul 2026 22:20:27 +0200 Subject: [PATCH 01/14] Add budget management and usage analytics system This commit introduces a comprehensive budget and analytics system with: **Backend:** - New database migrations for budgets, budget_assignments, usage_events, and model_pricing_overrides tables - Budget types with support for pooled/per-user kinds, flexible intervals (daily/weekly/monthly/total), reset strategies (calendar/rolling/anchored), and exceed actions (block/warn/allow) - Usage event tracking with token counts and costs - Admin routes for budget CRUD, assignments, and analytics queries (grouped by model/user/team/day/day_model) - Public routes for user budget status and personal analytics - Model pricing overrides stored in JSONB for custom per-model pricing - Budget enforcement integration in model listing (budget_blocked flag) **Frontend:** - Budget management page with create/edit/delete, team/user assignments - Analytics dashboard with overview/trends/explore tabs, stacked bar charts, area charts, and horizontal bar charts - Reusable chart components (StackedAnalyticsChart, HorizontalAnalyticsChart) - Budget indicator in sidebar showing remaining budget and usage progress bar - Model selector integration showing budget-blocked models - Profile page tabs for personal analytics - Model editor pricing override section **Version:** Bumped from 0.1.8 to 0.2.0 --- Cargo.lock | 8 +- Cargo.toml | 2 +- frontend/components/AppSidebar.vue | 31 + frontend/components/chat/ModelSelector.vue | 22 +- .../settings/AnalyticsDashboard.vue | 671 ++++++++++++++++++ .../settings/HorizontalAnalyticsChart.vue | 157 ++++ .../settings/StackedAnalyticsChart.vue | 249 +++++++ frontend/pages/settings.vue | 4 +- frontend/pages/settings/analytics.vue | 8 + frontend/pages/settings/budgets.vue | 358 ++++++++++ frontend/pages/settings/models/[id].vue | 106 ++- frontend/pages/settings/profile.vue | 101 +-- frontend/stores/budgetStore.ts | 110 +++ frontend/stores/chatStore.ts | 3 + frontend/types/budgets.ts | 75 ++ frontend/types/chat.ts | 1 + migrations/20260706000000_budgets.sql | 201 ++++++ migrations/20260706100000_analytics_i18n.sql | 38 + src/ai.rs | 14 + src/routes/admin/analytics.rs | 36 + src/routes/admin/budgets.rs | 173 +++++ src/routes/admin/mod.rs | 2 + src/routes/admin/models.rs | 82 ++- src/routes/mod.rs | 14 + src/routes/public/models.rs | 17 +- src/routes/public/streaming.rs | 43 +- src/routes/public/users.rs | 45 +- src/types/budgets/mod.rs | 39 + src/types/budgets/repository.rs | 431 +++++++++++ src/types/budgets/requests.rs | 40 ++ src/types/budgets/responses.rs | 93 +++ src/types/budgets/rows.rs | 22 + src/types/mod.rs | 4 + src/types/models/mod.rs | 11 + src/types/models/repository.rs | 121 +++- src/types/models/requests.rs | 5 + src/types/models/responses.rs | 1 + src/types/models/rows.rs | 1 + src/types/permissions/mod.rs | 3 + src/types/teams/repository.rs | 8 +- src/types/usage/mod.rs | 63 ++ src/types/usage/repository.rs | 173 +++++ src/utils/providers.rs | 4 +- 43 files changed, 3516 insertions(+), 74 deletions(-) create mode 100644 frontend/components/settings/AnalyticsDashboard.vue create mode 100644 frontend/components/settings/HorizontalAnalyticsChart.vue create mode 100644 frontend/components/settings/StackedAnalyticsChart.vue create mode 100644 frontend/pages/settings/analytics.vue create mode 100644 frontend/pages/settings/budgets.vue create mode 100644 frontend/stores/budgetStore.ts create mode 100644 frontend/types/budgets.ts create mode 100644 migrations/20260706000000_budgets.sql create mode 100644 migrations/20260706100000_analytics_i18n.sql create mode 100644 src/routes/admin/analytics.rs create mode 100644 src/routes/admin/budgets.rs create mode 100644 src/types/budgets/mod.rs create mode 100644 src/types/budgets/repository.rs create mode 100644 src/types/budgets/requests.rs create mode 100644 src/types/budgets/responses.rs create mode 100644 src/types/budgets/rows.rs create mode 100644 src/types/usage/mod.rs create mode 100644 src/types/usage/repository.rs diff --git a/Cargo.lock b/Cargo.lock index ed511aa..f0022d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OxideChat" -version = "0.1.8" +version = "0.2.0" dependencies = [ "aes-gcm", "arc-swap", @@ -2247,6 +2247,8 @@ dependencies = [ [[package]] name = "omniference" version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a14d600b1ba2cd3630f430a9869a5dd7cb45f0e0eec3f2afdb7ac3fc0a0907c3" dependencies = [ "anyhow", "async-stream", @@ -5238,3 +5240,7 @@ dependencies = [ "cc", "pkg-config", ] + +[[patch.unused]] +name = "omniference" +version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 45052ec..365ae36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OxideChat" -version = "0.1.8" +version = "0.2.0" edition = "2024" description = "A modern, performance and on customization focused AI chat application" license = "MIT" diff --git a/frontend/components/AppSidebar.vue b/frontend/components/AppSidebar.vue index 74ab08e..984d1bb 100644 --- a/frontend/components/AppSidebar.vue +++ b/frontend/components/AppSidebar.vue @@ -67,6 +67,25 @@ + + @@ -88,9 +107,11 @@ import {ChevronUp, LogOut, Settings, Settings2, Layers} from 'lucide-vue-next'; import {useMainStore} from '@/stores'; import {useChatStore} from '@/stores/chatStore'; +import {useBudgetStore} from '@/stores/budgetStore'; const store = useMainStore(); const chatStore = useChatStore(); +const budgetStore = useBudgetStore(); const router = useRouter(); const showWorkspaceManager = ref(false); @@ -108,4 +129,14 @@ async function handleLogout() { await store.logout(); router.push('/auth/login'); } + +function formatMoney(value: number) { + return `$${value.toFixed(2)}`; +} + +onMounted(() => { + if (store.auth.isAuthenticated) { + budgetStore.fetchMyBudget().catch(() => {}); + } +}); diff --git a/frontend/components/chat/ModelSelector.vue b/frontend/components/chat/ModelSelector.vue index ca59c65..867b49f 100644 --- a/frontend/components/chat/ModelSelector.vue +++ b/frontend/components/chat/ModelSelector.vue @@ -54,7 +54,8 @@
@@ -86,6 +87,9 @@

{{ model.model_id }}

+

+ {{ store.getTranslation('settings.budgets.reached') }} +

@@ -196,7 +200,7 @@ diff --git a/frontend/components/settings/HorizontalAnalyticsChart.vue b/frontend/components/settings/HorizontalAnalyticsChart.vue new file mode 100644 index 0000000..2c9515b --- /dev/null +++ b/frontend/components/settings/HorizontalAnalyticsChart.vue @@ -0,0 +1,157 @@ + + + diff --git a/frontend/components/settings/StackedAnalyticsChart.vue b/frontend/components/settings/StackedAnalyticsChart.vue new file mode 100644 index 0000000..90552a6 --- /dev/null +++ b/frontend/components/settings/StackedAnalyticsChart.vue @@ -0,0 +1,249 @@ + + + diff --git a/frontend/pages/settings.vue b/frontend/pages/settings.vue index 08460e5..171353d 100644 --- a/frontend/pages/settings.vue +++ b/frontend/pages/settings.vue @@ -45,7 +45,7 @@ diff --git a/frontend/pages/settings/budgets.vue b/frontend/pages/settings/budgets.vue new file mode 100644 index 0000000..cefed83 --- /dev/null +++ b/frontend/pages/settings/budgets.vue @@ -0,0 +1,358 @@ + + + diff --git a/frontend/pages/settings/models/[id].vue b/frontend/pages/settings/models/[id].vue index 8ed0960..a417b63 100644 --- a/frontend/pages/settings/models/[id].vue +++ b/frontend/pages/settings/models/[id].vue @@ -837,15 +837,61 @@
-
+
+
+
+

{{ store.getTranslation('settings.models.editor.pricing') }}

+

{{ store.getTranslation('settings.models.editor.pricing_desc') }}

+
+ + + +
+
+
+

{{ store.getTranslation('settings.models.editor.reported_price') }}

+

{{ formatPricingPair(pricing?.reported_input, pricing?.reported_output) }}

+
+
+

{{ store.getTranslation('settings.models.editor.effective_price') }}

+

{{ formatPricingPair(pricing?.effective_input, pricing?.effective_output) }}

+
+
+ {{ store.getTranslation('settings.models.editor.override_input') }} + +
+
+ {{ store.getTranslation('settings.models.editor.override_output') }} + +
+
+ {{ store.getTranslation('settings.models.editor.override_reasoning') }} + +
+
+ {{ store.getTranslation('settings.models.editor.override_cache_read') }} + +
+
+ {{ store.getTranslation('settings.models.editor.override_cache_write') }} + +
+
+

{{ store.getTranslation('settings.models.editor.pricing_unit') }}

+
+ {{ store.getTranslation('common.save') }} +
+
+ +
{{ store.getTranslation('settings.models.editor.providers_user_unavailable') }}
-
+
{{ store.getTranslation('settings.models.editor.providers_empty') }}
-
+
@@ -940,6 +986,14 @@ const providerOptions = ref | null>(null); const providerOptionsLoading = ref(false); const providerOptionsLoaded = ref(false); const providerParentUnavailable = computed(() => providerOptions.value?.availability_state === 'USER_UNAVAILABLE'); +const pricing = ref | null>(null); +const pricingForm = reactive({ + input: '0', + output: '0', + reasoning: '', + cache_read: '', + cache_write: '', +}); type ProviderRowState = 'user_unavailable' | 'unavailable' | 'available' | 'unknown'; @@ -975,6 +1029,51 @@ function formatPrice(opt: Record): string { return `${fmt(opt.price_input)} / ${fmt(opt.price_output)}`; } +function formatPricingPair(input: string | number | null | undefined, output: string | number | null | undefined): string { + const fmt = (value: string | number | null | undefined) => (value == null ? '-' : '$' + Number(value).toFixed(6)); + return `${fmt(input)} / ${fmt(output)}`; +} + +async function loadPricing() { + try { + pricing.value = await $customFetch(`/api/v1/admin/models/${modelId}/pricing`); + const override = pricing.value?.override_pricing ?? null; + pricingForm.input = String(override?.input ?? pricing.value?.effective_input ?? 0); + pricingForm.output = String(override?.output ?? pricing.value?.effective_output ?? 0); + pricingForm.reasoning = override?.reasoning != null ? String(override.reasoning) : ''; + pricingForm.cache_read = override?.cache_read != null ? String(override.cache_read) : ''; + pricingForm.cache_write = override?.cache_write != null ? String(override.cache_write) : ''; + } catch (e) { + console.error(e); + } +} + +async function savePricingOverride() { + const optional = (value: string): number | undefined => { + const trimmed = value.trim(); + return trimmed === '' ? undefined : Number(trimmed); + }; + const overridePricing: Record = { + input: Number(pricingForm.input || 0), + output: Number(pricingForm.output || 0), + reasoning: optional(pricingForm.reasoning), + cache_read: optional(pricingForm.cache_read), + cache_write: optional(pricingForm.cache_write), + tiers: [], + }; + pricing.value = await $customFetch(`/api/v1/admin/models/${modelId}/pricing`, { + method: 'PUT', + body: {pricing: overridePricing}, + }); + store.toast(store.getTranslation('settings.models.editor.pricing_saved'), {type: 'success'}); +} + +async function deletePricingOverride() { + await $customFetch(`/api/v1/admin/models/${modelId}/pricing`, {method: 'DELETE'}); + await loadPricing(); + store.toast(store.getTranslation('settings.models.editor.pricing_deleted'), {type: 'success'}); +} + async function loadProviderOptions(force = false) { providerOptionsLoading.value = true; try { @@ -1174,6 +1273,7 @@ function insertVariable(token: string) { onMounted(async () => { await loadModel(); + await loadPricing(); }); function populateForm(data: Record) { diff --git a/frontend/pages/settings/profile.vue b/frontend/pages/settings/profile.vue index 1500499..12b4ac9 100644 --- a/frontend/pages/settings/profile.vue +++ b/frontend/pages/settings/profile.vue @@ -1,52 +1,60 @@ @@ -55,6 +63,7 @@ import {Copy} from 'lucide-vue-next'; import {useMainStore} from '@/stores'; const store = useMainStore(); +const activeProfileTab = ref('profile'); const userInitials = computed(() => { const username = store.auth.user?.username || 'U'; diff --git a/frontend/stores/budgetStore.ts b/frontend/stores/budgetStore.ts new file mode 100644 index 0000000..2fc1810 --- /dev/null +++ b/frontend/stores/budgetStore.ts @@ -0,0 +1,110 @@ +import {defineStore} from 'pinia'; +import type {AnalyticsDayModelRow, AnalyticsRow, Budget, BudgetAssignmentInfo, BudgetPayload, UserBudgetStatus} from '~/types/budgets'; +import type {PaginatedResponse} from '~/types/chat'; + +export const useBudgetStore = defineStore('budget', { + state: () => ({ + myStatus: null as UserBudgetStatus | null, + budgets: [] as Budget[], + analytics: { + byModel: [] as AnalyticsRow[], + byUser: [] as AnalyticsRow[], + byTeam: [] as AnalyticsRow[], + byDay: [] as AnalyticsRow[], + byDayModel: [] as AnalyticsDayModelRow[], + }, + myAnalytics: { + byModel: [] as AnalyticsRow[], + byDay: [] as AnalyticsRow[], + byDayModel: [] as AnalyticsDayModelRow[], + }, + assignments: [] as BudgetAssignmentInfo[], + loading: false, + }), + getters: { + lowestRemaining(state): number | null { + if (!state.myStatus?.budgets.length) return null; + return Math.min(...state.myStatus.budgets.map(budget => Number(budget.remaining))); + }, + highestUsagePercent(state): number { + if (!state.myStatus?.budgets.length) return 0; + return Math.max( + ...state.myStatus.budgets.map((budget) => { + const amount = Number(budget.amount); + return amount > 0 ? Math.min(100, (Number(budget.spent) / amount) * 100) : 0; + }), + ); + }, + }, + actions: { + async fetchMyBudget() { + const {$customFetch} = useNuxtApp(); + this.myStatus = await $customFetch('/api/v1/me/budget'); + return this.myStatus; + }, + async fetchBudgets() { + const {$customFetch} = useNuxtApp(); + const response = await $customFetch>('/api/v1/admin/budgets?size=100'); + this.budgets = response?.items ?? []; + return this.budgets; + }, + async createBudget(payload: BudgetPayload) { + const {$customFetch} = useNuxtApp(); + const budget = await $customFetch('/api/v1/admin/budgets', {method: 'POST', body: payload}); + await this.fetchBudgets(); + return budget; + }, + async updateBudget(id: string, payload: Partial) { + const {$customFetch} = useNuxtApp(); + const budget = await $customFetch(`/api/v1/admin/budgets/${id}`, {method: 'PATCH', body: payload}); + await this.fetchBudgets(); + return budget; + }, + async deleteBudget(id: string) { + const {$customFetch} = useNuxtApp(); + await $customFetch(`/api/v1/admin/budgets/${id}`, {method: 'DELETE'}); + await this.fetchBudgets(); + }, + async fetchAssignments(budgetId: string) { + const {$customFetch} = useNuxtApp(); + this.assignments = await $customFetch(`/api/v1/admin/budgets/${budgetId}/assignments`); + return this.assignments; + }, + async assignBudget(id: string, body: {team_id?: string | null; user_id?: string | null}) { + const {$customFetch} = useNuxtApp(); + await $customFetch(`/api/v1/admin/budgets/${id}/assignments`, {method: 'POST', body}); + await this.fetchAssignments(id); + }, + async removeAssignment(budgetId: string, assignmentId: string) { + const {$customFetch} = useNuxtApp(); + await $customFetch(`/api/v1/admin/budgets/${budgetId}/assignments/${assignmentId}`, {method: 'DELETE'}); + await this.fetchAssignments(budgetId); + }, + async fetchAllAnalytics(params: {from?: string; to?: string} = {}) { + const {$customFetch} = useNuxtApp(); + const [byModel, byUser, byTeam, byDay, byDayModel] = await Promise.all([ + $customFetch('/api/v1/admin/analytics', {params: {...params, group_by: 'model'}}), + $customFetch('/api/v1/admin/analytics', {params: {...params, group_by: 'user'}}), + $customFetch('/api/v1/admin/analytics', {params: {...params, group_by: 'team'}}), + $customFetch('/api/v1/admin/analytics', {params: {...params, group_by: 'day'}}), + $customFetch('/api/v1/admin/analytics', {params: {...params, group_by: 'day_model'}}), + ]); + this.analytics.byModel = byModel ?? []; + this.analytics.byUser = byUser ?? []; + this.analytics.byTeam = byTeam ?? []; + this.analytics.byDay = byDay ?? []; + this.analytics.byDayModel = byDayModel ?? []; + }, + async fetchMyAnalytics(params: {from?: string; to?: string} = {}) { + const {$customFetch} = useNuxtApp(); + const [byModel, byDay, byDayModel] = await Promise.all([ + $customFetch('/api/v1/me/analytics', {params: {...params, group_by: 'model'}}), + $customFetch('/api/v1/me/analytics', {params: {...params, group_by: 'day'}}), + $customFetch('/api/v1/me/analytics', {params: {...params, group_by: 'day_model'}}), + ]); + this.myAnalytics.byModel = byModel ?? []; + this.myAnalytics.byDay = byDay ?? []; + this.myAnalytics.byDayModel = byDayModel ?? []; + }, + }, +}); diff --git a/frontend/stores/chatStore.ts b/frontend/stores/chatStore.ts index 30c6fb4..d00aab9 100644 --- a/frontend/stores/chatStore.ts +++ b/frontend/stores/chatStore.ts @@ -1,6 +1,7 @@ import {defineStore} from 'pinia'; import {useMainStore} from './index'; import {useThemeStore} from './theme'; +import {useBudgetStore} from './budgetStore'; import type { McpServer, Workspace, @@ -767,6 +768,8 @@ export const useChatStore = defineStore('chat', { chat.message_count += 2; chat.updated_at = new Date().toISOString(); } + const budgetStore = useBudgetStore(); + budgetStore.fetchMyBudget().catch(() => {}); break; case 'error': const store = useMainStore(); diff --git a/frontend/types/budgets.ts b/frontend/types/budgets.ts new file mode 100644 index 0000000..bf778cc --- /dev/null +++ b/frontend/types/budgets.ts @@ -0,0 +1,75 @@ +export interface Budget { + id: string; + name: string; + description: string | null; + amount: string; + kind: 'pooled' | 'per_user'; + interval: 'daily' | 'weekly' | 'monthly' | 'total'; + reset_strategy: 'calendar' | 'rolling' | 'anchored'; + on_exceed: 'block' | 'warn' | 'allow'; + is_enabled: boolean; + created_at: string; + updated_at: string; +} + +export interface EffectiveBudget { + budget: Budget; + assignment_id: string; + team_id: string | null; + user_id: string | null; + amount: string; + spent: string; + remaining: string; + window_start: string; + resets_at: string | null; + on_exceed: 'block' | 'warn' | 'allow'; + exhausted: boolean; +} + +export interface UserBudgetStatus { + budgets: EffectiveBudget[]; + decision: 'block' | 'warn' | 'allow'; + blocked_model_ids: string[]; +} + +export interface AnalyticsRow { + id: string | null; + label: string; + input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + cost_total: string; + request_count: number; +} + +export interface AnalyticsDayModelRow { + day: string; + model_id: string | null; + model_name: string; + input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + cost_total: string; + request_count: number; +} + +export interface BudgetAssignmentInfo { + id: string; + budget_id: string; + team_id: string | null; + team_name: string | null; + user_id: string | null; + user_label: string | null; + assigned_at: string; +} + +export interface BudgetPayload { + name: string; + description?: string | null; + amount: string; + kind: 'pooled' | 'per_user'; + interval: 'daily' | 'weekly' | 'monthly' | 'total'; + reset_strategy: 'calendar' | 'rolling' | 'anchored'; + on_exceed: 'block' | 'warn' | 'allow'; + is_enabled?: boolean; +} diff --git a/frontend/types/chat.ts b/frontend/types/chat.ts index 41da78e..f2767ad 100644 --- a/frontend/types/chat.ts +++ b/frontend/types/chat.ts @@ -272,6 +272,7 @@ export interface ModelList { provider_name: string; icon: string | null; is_favorite: boolean; + budget_blocked: boolean; } export interface ModelListAdmin { diff --git a/migrations/20260706000000_budgets.sql b/migrations/20260706000000_budgets.sql new file mode 100644 index 0000000..1e41e3e --- /dev/null +++ b/migrations/20260706000000_budgets.sql @@ -0,0 +1,201 @@ +DO $$ BEGIN + CREATE TYPE budget_kind AS ENUM ('pooled', 'per_user'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE budget_interval AS ENUM ('daily', 'weekly', 'monthly', 'total'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE budget_reset_strategy AS ENUM ('calendar', 'rolling', 'anchored'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE budget_on_exceed AS ENUM ('block', 'warn', 'allow'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +CREATE TABLE IF NOT EXISTS budgets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(120) NOT NULL, + description TEXT, + amount NUMERIC(12,4) NOT NULL, + kind budget_kind NOT NULL, + interval budget_interval NOT NULL, + reset_strategy budget_reset_strategy NOT NULL DEFAULT 'calendar', + on_exceed budget_on_exceed NOT NULL DEFAULT 'block', + is_enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS budget_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + budget_id UUID NOT NULL REFERENCES budgets(id) ON DELETE CASCADE, + team_id UUID REFERENCES teams(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT budget_assignments_target_check CHECK ( + (team_id IS NOT NULL AND user_id IS NULL) OR + (team_id IS NULL AND user_id IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS budget_assignments_team_budget_unique + ON budget_assignments (team_id, budget_id) + WHERE team_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS budget_assignments_user_budget_unique + ON budget_assignments (user_id, budget_id) + WHERE user_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS usage_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + team_id UUID REFERENCES teams(id) ON DELETE SET NULL, + model_id UUID REFERENCES models(id) ON DELETE SET NULL, + provider_id UUID REFERENCES providers(id) ON DELETE SET NULL, + request_type VARCHAR(50) NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + cost_total NUMERIC(12,6) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_usage_events_user_created ON usage_events(user_id, created_at); +CREATE INDEX IF NOT EXISTS idx_usage_events_model_created ON usage_events(model_id, created_at); +CREATE INDEX IF NOT EXISTS idx_usage_events_team_created ON usage_events(team_id, created_at); + +CREATE TABLE IF NOT EXISTS model_pricing_overrides ( + model_id UUID PRIMARY KEY REFERENCES models(id) ON DELETE CASCADE, + pricing JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +INSERT INTO permissions (name, description) VALUES + ('admin.budgets.view', 'View budget configurations'), + ('admin.budgets.edit', 'Manage budget configurations'), + ('admin.analytics.view', 'View usage analytics') +ON CONFLICT (name) DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id FROM roles r, permissions p +WHERE r.name = 'admin' + AND p.name IN ('admin.budgets.view', 'admin.budgets.edit', 'admin.analytics.view') +ON CONFLICT DO NOTHING; + +INSERT INTO i18n_translations (language, key_path, value) VALUES + ('en', 'settings.tabs.budgets', 'Budgets'), + ('en', 'settings.tabs.analytics', 'Analytics'), + ('en', 'settings.budgets.description', 'Limit spend for users and teams'), + ('en', 'settings.budgets.create', 'Create budget'), + ('en', 'settings.budgets.name', 'Name'), + ('en', 'settings.budgets.amount', 'Amount'), + ('en', 'settings.budgets.kind', 'Kind'), + ('en', 'settings.budgets.interval', 'Interval'), + ('en', 'settings.budgets.reset_strategy', 'Reset'), + ('en', 'settings.budgets.on_exceed', 'Action'), + ('en', 'settings.budgets.enabled', 'Enabled'), + ('en', 'settings.budgets.assign_team', 'Assign team'), + ('en', 'settings.budgets.assign_user', 'Assign user'), + ('en', 'settings.budgets.used', 'Used'), + ('en', 'settings.budgets.remaining', 'Remaining'), + ('en', 'settings.budgets.notes', 'Description'), + ('en', 'settings.budgets.assignments', 'Assignments'), + ('en', 'settings.budgets.no_assignments', 'No assignments yet'), + ('en', 'settings.budgets.pick_team', 'Select a team…'), + ('en', 'settings.budgets.pick_user', 'Select a user…'), + ('en', 'settings.budgets.reached', 'Budget reached'), + ('en', 'settings.analytics.total_cost', 'Total cost'), + ('en', 'settings.analytics.total_tokens', 'Total tokens'), + ('en', 'settings.analytics.avg_cost', 'Avg. cost / day'), + ('en', 'settings.analytics.top_models', 'By model'), + ('en', 'settings.analytics.top_users', 'By user'), + ('en', 'settings.analytics.top_teams', 'By team'), + ('en', 'settings.analytics.spend_over_time', 'Spend over time'), + ('en', 'settings.analytics.token_split', 'Token split'), + ('en', 'settings.analytics.label', 'Label'), + ('en', 'settings.analytics.last_7d', 'Last 7 days'), + ('en', 'settings.analytics.last_30d', 'Last 30 days'), + ('en', 'settings.analytics.last_90d', 'Last 90 days'), + ('en', 'settings.analytics.custom', 'Custom'), + ('en', 'settings.analytics.description', 'Review token and spend trends'), + ('en', 'settings.analytics.group_by', 'Group by'), + ('en', 'settings.analytics.from', 'From'), + ('en', 'settings.analytics.to', 'To'), + ('en', 'settings.analytics.cost', 'Cost'), + ('en', 'settings.analytics.tokens', 'Tokens'), + ('en', 'settings.models.editor.pricing', 'Pricing'), + ('en', 'settings.models.editor.pricing_desc', 'Override accounting prices per one million tokens.'), + ('en', 'settings.models.editor.reported_price', 'Reported price'), + ('en', 'settings.models.editor.effective_price', 'Effective price'), + ('en', 'settings.models.editor.override_input', 'Input override'), + ('en', 'settings.models.editor.override_output', 'Output override'), + ('en', 'settings.models.editor.override_reasoning', 'Reasoning override'), + ('en', 'settings.models.editor.override_cache_read', 'Cache read override'), + ('en', 'settings.models.editor.override_cache_write', 'Cache write override'), + ('en', 'settings.models.editor.pricing_unit', 'Rates are per one million tokens. Leave optional fields empty to keep them unset.'), + ('en', 'settings.models.editor.pricing_saved', 'Pricing saved'), + ('en', 'settings.models.editor.pricing_deleted', 'Pricing override removed'), + ('en', 'sidebar.budget.remaining', '{amount} left'), + ('de', 'settings.tabs.budgets', 'Budgets'), + ('de', 'settings.tabs.analytics', 'Analysen'), + ('de', 'settings.budgets.description', 'Ausgaben fuer Benutzer und Teams begrenzen'), + ('de', 'settings.budgets.create', 'Budget erstellen'), + ('de', 'settings.budgets.name', 'Name'), + ('de', 'settings.budgets.amount', 'Betrag'), + ('de', 'settings.budgets.kind', 'Art'), + ('de', 'settings.budgets.interval', 'Intervall'), + ('de', 'settings.budgets.reset_strategy', 'Zuruecksetzen'), + ('de', 'settings.budgets.on_exceed', 'Aktion'), + ('de', 'settings.budgets.enabled', 'Aktiv'), + ('de', 'settings.budgets.assign_team', 'Team zuweisen'), + ('de', 'settings.budgets.assign_user', 'Benutzer zuweisen'), + ('de', 'settings.budgets.used', 'Verbraucht'), + ('de', 'settings.budgets.remaining', 'Verbleibend'), + ('de', 'settings.budgets.notes', 'Beschreibung'), + ('de', 'settings.budgets.assignments', 'Zuweisungen'), + ('de', 'settings.budgets.no_assignments', 'Noch keine Zuweisungen'), + ('de', 'settings.budgets.pick_team', 'Team auswaehlen…'), + ('de', 'settings.budgets.pick_user', 'Benutzer auswaehlen…'), + ('de', 'settings.budgets.reached', 'Budget erreicht'), + ('de', 'settings.analytics.total_cost', 'Gesamtkosten'), + ('de', 'settings.analytics.total_tokens', 'Gesamte Tokens'), + ('de', 'settings.analytics.avg_cost', 'Durchschn. Kosten / Tag'), + ('de', 'settings.analytics.top_models', 'Nach Modell'), + ('de', 'settings.analytics.top_users', 'Nach Benutzer'), + ('de', 'settings.analytics.top_teams', 'Nach Team'), + ('de', 'settings.analytics.spend_over_time', 'Ausgaben ueber Zeit'), + ('de', 'settings.analytics.token_split', 'Token-Aufteilung'), + ('de', 'settings.analytics.label', 'Bezeichnung'), + ('de', 'settings.analytics.last_7d', 'Letzte 7 Tage'), + ('de', 'settings.analytics.last_30d', 'Letzte 30 Tage'), + ('de', 'settings.analytics.last_90d', 'Letzte 90 Tage'), + ('de', 'settings.analytics.custom', 'Benutzerdefiniert'), + ('de', 'settings.analytics.description', 'Token- und Ausgabentrends pruefen'), + ('de', 'settings.analytics.group_by', 'Gruppieren nach'), + ('de', 'settings.analytics.from', 'Von'), + ('de', 'settings.analytics.to', 'Bis'), + ('de', 'settings.analytics.cost', 'Kosten'), + ('de', 'settings.analytics.tokens', 'Tokens'), + ('de', 'settings.models.editor.pricing', 'Preise'), + ('de', 'settings.models.editor.pricing_desc', 'Abrechnungspreise pro eine Million Tokens ueberschreiben.'), + ('de', 'settings.models.editor.reported_price', 'Gemeldeter Preis'), + ('de', 'settings.models.editor.effective_price', 'Effektiver Preis'), + ('de', 'settings.models.editor.override_input', 'Eingabe-Ueberschreibung'), + ('de', 'settings.models.editor.override_output', 'Ausgabe-Ueberschreibung'), + ('de', 'settings.models.editor.override_reasoning', 'Reasoning-Ueberschreibung'), + ('de', 'settings.models.editor.override_cache_read', 'Cache-Lese-Ueberschreibung'), + ('de', 'settings.models.editor.override_cache_write', 'Cache-Schreib-Ueberschreibung'), + ('de', 'settings.models.editor.pricing_unit', 'Preise gelten pro eine Million Tokens. Optionale Felder leer lassen, um sie nicht zu setzen.'), + ('de', 'settings.models.editor.pricing_saved', 'Preise gespeichert'), + ('de', 'settings.models.editor.pricing_deleted', 'Preis-Ueberschreibung entfernt'), + ('de', 'sidebar.budget.remaining', '{amount} uebrig') +ON CONFLICT (language, key_path) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW(); diff --git a/migrations/20260706100000_analytics_i18n.sql b/migrations/20260706100000_analytics_i18n.sql new file mode 100644 index 0000000..9b06dba --- /dev/null +++ b/migrations/20260706100000_analytics_i18n.sql @@ -0,0 +1,38 @@ +INSERT INTO i18n_translations (language, key_path, value) +VALUES + ('en', 'settings.analytics.my_usage', 'My Usage'), + ('en', 'settings.analytics.requests', 'Requests'), + ('en', 'settings.analytics.per_day', 'per day'), + ('en', 'settings.analytics.usage_by_model', 'Usage by model'), + ('en', 'settings.analytics.request_volume', 'Request volume by model'), + ('en', 'settings.analytics.no_data', 'No data available for this period'), + ('en', 'settings.analytics.trending', 'Trending'), + ('en', 'settings.analytics.tab_overview', 'Overview'), + ('en', 'settings.analytics.tab_trends', 'Trends'), + ('en', 'settings.analytics.tab_explore', 'Explore'), + ('en', 'settings.analytics.metric', 'Metric'), + ('en', 'settings.analytics.model', 'Model'), + ('en', 'settings.analytics.none', 'None'), + ('en', 'settings.analytics.value', 'Value'), + ('en', 'settings.analytics.tokens_input', 'Tokens (Input)'), + ('en', 'settings.analytics.tokens_output', 'Tokens (Output)'), + ('en', 'settings.analytics.tokens_reasoning', 'Tokens (Reasoning)'), + + ('de', 'settings.analytics.my_usage', 'Meine Nutzung'), + ('de', 'settings.analytics.requests', 'Anfragen'), + ('de', 'settings.analytics.per_day', 'pro Tag'), + ('de', 'settings.analytics.usage_by_model', 'Nutzung nach Modell'), + ('de', 'settings.analytics.request_volume', 'Anfrageanzahl nach Modell'), + ('de', 'settings.analytics.no_data', 'Keine Daten fuer diesen Zeitraum verfuegbar'), + ('de', 'settings.analytics.trending', 'Trend'), + ('de', 'settings.analytics.tab_overview', 'Uebersicht'), + ('de', 'settings.analytics.tab_trends', 'Trends'), + ('de', 'settings.analytics.tab_explore', 'Erkunden'), + ('de', 'settings.analytics.metric', 'Metrik'), + ('de', 'settings.analytics.model', 'Modell'), + ('de', 'settings.analytics.none', 'Keine'), + ('de', 'settings.analytics.value', 'Wert'), + ('de', 'settings.analytics.tokens_input', 'Tokens (Eingabe)'), + ('de', 'settings.analytics.tokens_output', 'Tokens (Ausgabe)'), + ('de', 'settings.analytics.tokens_reasoning', 'Tokens (Reasoning)') +ON CONFLICT (language, key_path) DO UPDATE SET value = EXCLUDED.value; diff --git a/src/ai.rs b/src/ai.rs index 6ea7b3f..d714cea 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -45,6 +45,7 @@ pub async fn init(pool: &PgPool) { drop(engine_write); let _ = OF_ENGINE.set(engine); + sync_pricing_overrides(pool).await; println!("[AI] Engine initialized with {} system providers", provider_count); } @@ -54,6 +55,17 @@ pub fn get() -> Arc> { OF_ENGINE.get().expect("AI engine not initialized").clone() } +/// Get the shared omniference catalog, if the engine is initialized. +pub async fn catalog() -> Option> { + let engine = OF_ENGINE.get()?; + let guard = engine.read().await; + Some(guard.service().catalog.clone()) +} + +/// Pricing overrides are stored in the database and applied at usage-event +/// accounting time — no catalog integration needed. +pub async fn sync_pricing_overrides(_pool: &PgPool) {} + /// Reload providers from the database pub async fn reload_providers(pool: &PgPool) { let providers = Provider::list_enabled_system(pool).await.unwrap_or_default(); @@ -72,6 +84,8 @@ pub async fn reload_providers(pool: &PgPool) { eprintln!("[AI] Failed to register provider '{}': {}", provider.name, e); } } + drop(engine_write); + sync_pricing_overrides(pool).await; println!("[AI] Reloaded {} providers", provider_count); } diff --git a/src/routes/admin/analytics.rs b/src/routes/admin/analytics.rs new file mode 100644 index 0000000..31c85ec --- /dev/null +++ b/src/routes/admin/analytics.rs @@ -0,0 +1,36 @@ +use crate::routes::public::auth::get_current_user; +use crate::types::consts::ADMIN_ANALYTICS_VIEW; +use crate::types::{AnalyticsQuery, JobState, UsageEvent}; +use crate::utils::response::{ErrorBuilder, ErrorCode, ResponseBody, ResponseBuilder}; +use axum::{ + extract::{Query, State}, + response::IntoResponse, +}; +use std::sync::Arc; +use tower_cookies::Cookies; + +pub async fn get_analytics(State(state): State>, cookies: Cookies, Query(params): Query) -> impl IntoResponse { + let Some(user) = get_current_user(&state.db, &cookies).await else { + return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); + }; + if !user.has_permission(&state.db, ADMIN_ANALYTICS_VIEW).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + let group_by = params.group_by.as_deref().unwrap_or("model"); + if group_by == "day_model" { + return match UsageEvent::day_model_analytics(&state.db, params.from, params.to, None).await { + Ok(rows) => ResponseBuilder::new(ResponseBody::Json(rows)).build(), + Err(e) => { + eprintln!("[ANALYTICS] Failed to load day_model analytics: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + }; + } + match UsageEvent::analytics(&state.db, params.from, params.to, group_by).await { + Ok(rows) => ResponseBuilder::new(ResponseBody::Json(rows)).build(), + Err(e) => { + eprintln!("[ANALYTICS] Failed to load analytics: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + } +} diff --git a/src/routes/admin/budgets.rs b/src/routes/admin/budgets.rs new file mode 100644 index 0000000..59c41db --- /dev/null +++ b/src/routes/admin/budgets.rs @@ -0,0 +1,173 @@ +use crate::routes::public::auth::get_current_user; +use crate::types::consts::{ADMIN_BUDGETS_EDIT, ADMIN_BUDGETS_VIEW}; +use crate::types::{Budget, BudgetAssignmentRequest, CreateBudgetRequest, JobState, ListBudgetsQuery, UpdateBudgetRequest}; +use crate::utils::response::{ErrorBuilder, ErrorCode, ResponseBody, ResponseBuilder}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, +}; +use std::sync::Arc; +use tower_cookies::Cookies; +use uuid::Uuid; + +pub async fn list_budgets(State(state): State>, cookies: Cookies, Query(params): Query) -> impl IntoResponse { + let Some(user) = get_current_user(&state.db, &cookies).await else { + return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); + }; + if !user.has_permission(&state.db, ADMIN_BUDGETS_VIEW).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + match Budget::list(&state.db, params.page.unwrap_or(1), params.size.unwrap_or(50), params.search.as_deref()).await { + Ok(response) => ResponseBuilder::new(ResponseBody::Json(response)).build(), + Err(e) => { + eprintln!("[BUDGETS] Failed to list budgets: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + } +} + +pub async fn create_budget(State(state): State>, cookies: Cookies, Json(req): Json) -> impl IntoResponse { + let Some(user) = get_current_user(&state.db, &cookies).await else { + return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); + }; + if !user.has_permission(&state.db, ADMIN_BUDGETS_EDIT).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + if req.name.trim().is_empty() { + return ErrorBuilder::new(ErrorCode::ValidationFailed).build(); + } + match Budget::create(&state.db, &req).await { + Ok(budget) => ResponseBuilder::new(ResponseBody::Json(crate::types::BudgetResponse::from(budget))) + .status(StatusCode::CREATED) + .build(), + Err(e) => { + eprintln!("[BUDGETS] Failed to create budget: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + } +} + +pub async fn update_budget(State(state): State>, cookies: Cookies, Path(id): Path, Json(req): Json) -> impl IntoResponse { + let Some(user) = get_current_user(&state.db, &cookies).await else { + return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); + }; + if !user.has_permission(&state.db, ADMIN_BUDGETS_EDIT).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + let budget = match Budget::find_by_id(&state.db, &id).await { + Ok(Some(budget)) => budget, + Ok(None) => return ErrorBuilder::new(ErrorCode::NotFound).build(), + Err(e) => { + eprintln!("[BUDGETS] Failed to fetch budget: {e}"); + return ErrorBuilder::new(ErrorCode::DatabaseError).build(); + } + }; + match budget.update(&state.db, &req).await { + Ok(updated) => ResponseBuilder::new(ResponseBody::Json(crate::types::BudgetResponse::from(updated))).build(), + Err(e) => { + eprintln!("[BUDGETS] Failed to update budget: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + } +} + +pub async fn delete_budget(State(state): State>, cookies: Cookies, Path(id): Path) -> impl IntoResponse { + let Some(user) = get_current_user(&state.db, &cookies).await else { + return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); + }; + if !user.has_permission(&state.db, ADMIN_BUDGETS_EDIT).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + let budget = match Budget::find_by_id(&state.db, &id).await { + Ok(Some(budget)) => budget, + Ok(None) => return ErrorBuilder::new(ErrorCode::NotFound).build(), + Err(e) => { + eprintln!("[BUDGETS] Failed to fetch budget for delete: {e}"); + return ErrorBuilder::new(ErrorCode::DatabaseError).build(); + } + }; + match budget.delete(&state.db).await { + Ok(()) => (StatusCode::NO_CONTENT, "").into_response(), + Err(e) => { + eprintln!("[BUDGETS] Failed to delete budget: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + } +} + +pub async fn assign_budget(State(state): State>, cookies: Cookies, Path(id): Path, Json(req): Json) -> impl IntoResponse { + let Some(user) = get_current_user(&state.db, &cookies).await else { + return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); + }; + if !user.has_permission(&state.db, ADMIN_BUDGETS_EDIT).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + let budget = match Budget::find_by_id(&state.db, &id).await { + Ok(Some(budget)) => budget, + Ok(None) => return ErrorBuilder::new(ErrorCode::NotFound).build(), + Err(e) => { + eprintln!("[BUDGETS] Failed to fetch budget for assignment: {e}"); + return ErrorBuilder::new(ErrorCode::DatabaseError).build(); + } + }; + match budget.assign(&state.db, &req).await { + Ok(()) => ResponseBuilder::<()>::new(ResponseBody::Empty).build(), + Err(e) => { + eprintln!("[BUDGETS] Failed to assign budget: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + } +} + +pub async fn get_budget_assignments(State(state): State>, cookies: Cookies, Path(id): Path) -> impl IntoResponse { + let Some(user) = get_current_user(&state.db, &cookies).await else { + return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); + }; + if !user.has_permission(&state.db, ADMIN_BUDGETS_VIEW).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + match Budget::list_assignments(&state.db, &id).await { + Ok(assignments) => ResponseBuilder::new(ResponseBody::Json(assignments)).build(), + Err(e) => { + eprintln!("[BUDGETS] Failed to list assignments: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + } +} + +pub async fn delete_assignment(State(state): State>, cookies: Cookies, Path((_budget_id, assignment_id)): Path<(Uuid, Uuid)>) -> impl IntoResponse { + let Some(user) = get_current_user(&state.db, &cookies).await else { + return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); + }; + if !user.has_permission(&state.db, ADMIN_BUDGETS_EDIT).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + match Budget::delete_assignment(&state.db, &assignment_id).await { + Ok(()) => (StatusCode::NO_CONTENT, "").into_response(), + Err(e) => { + eprintln!("[BUDGETS] Failed to delete assignment: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + } +} + +pub async fn unassign_budget(State(state): State>, cookies: Cookies, Json(req): Json) -> impl IntoResponse { + let Some(user) = get_current_user(&state.db, &cookies).await else { + return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); + }; + if !user.has_permission(&state.db, ADMIN_BUDGETS_EDIT).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + if req.team_id.is_none() && req.user_id.is_none() { + return ErrorBuilder::new(ErrorCode::ValidationFailed).build(); + } + match Budget::unassign(&state.db, &req).await { + Ok(()) => ResponseBuilder::<()>::new(ResponseBody::Empty).build(), + Err(e) => { + eprintln!("[BUDGETS] Failed to unassign budget: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + } +} diff --git a/src/routes/admin/mod.rs b/src/routes/admin/mod.rs index a1ad3cb..6a2d702 100644 --- a/src/routes/admin/mod.rs +++ b/src/routes/admin/mod.rs @@ -1,3 +1,5 @@ +pub mod analytics; +pub mod budgets; pub mod catalog; pub mod config; pub mod i18n; diff --git a/src/routes/admin/models.rs b/src/routes/admin/models.rs index 3252e58..ba66d26 100644 --- a/src/routes/admin/models.rs +++ b/src/routes/admin/models.rs @@ -1,6 +1,6 @@ use crate::routes::public::auth::get_current_user; use crate::types::JobState; -use crate::types::models::{AdminModelPatchBody, Model, ModelListParams}; +use crate::types::models::{AdminModelPatchBody, Model, ModelListParams, ModelPricing, ModelPricingOverrideRequest}; use crate::types::models_configs::{ModelConfig, ModelConfigPatchField}; use crate::utils::images::{image_url, is_data_uri, store_from_data_uri}; use crate::utils::response::{ErrorBuilder, ErrorCode, ResponseBody, ResponseBuilder}; @@ -101,6 +101,86 @@ pub async fn get_model(State(state): State>, cookies: Cookies, Pat ResponseBuilder::new(ResponseBody::Json(row)).build() } +pub async fn get_model_pricing(State(state): State>, cookies: Cookies, Path(id): Path) -> impl IntoResponse { + let user = match get_current_user(&state.db, &cookies).await { + Some(user) => user, + None => return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(), + }; + if !user.has_permission(&state.db, ADMIN_MODELS_VIEW).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + match ModelPricing::effective(&state.db, &id).await { + Ok(Some(pricing)) => ResponseBuilder::new(ResponseBody::Json(pricing)).build(), + Ok(None) => ErrorBuilder::new(ErrorCode::NotFound).build(), + Err(e) => { + eprintln!("[ADMIN] Failed to get model pricing: {e}"); + ErrorBuilder::new(ErrorCode::InternalError).build() + } + } +} + +pub async fn put_model_pricing( + State(state): State>, + cookies: Cookies, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let user = match get_current_user(&state.db, &cookies).await { + Some(user) => user, + None => return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(), + }; + if !user.has_permission(&state.db, ADMIN_MODELS_EDIT).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + let model = match Model::find_by_id(&state.db, &id).await { + Ok(Some(model)) => model, + Ok(None) => return ErrorBuilder::new(ErrorCode::NotFound).build(), + Err(e) => { + eprintln!("[ADMIN] Failed to load model for pricing: {e}"); + return ErrorBuilder::new(ErrorCode::InternalError).build(); + } + }; + let pricing_json = match serde_json::to_value(&req.pricing) { + Ok(value) => value, + Err(e) => { + eprintln!("[ADMIN] Failed to serialize pricing override: {e}"); + return ErrorBuilder::new(ErrorCode::InternalError).build(); + } + }; + match ModelPricing::upsert_override(&state.db, &id, &pricing_json).await { + Ok(pricing) => ResponseBuilder::new(ResponseBody::Json(pricing)).build(), + Err(e) => { + eprintln!("[ADMIN] Failed to save model pricing: {e}"); + ErrorBuilder::new(ErrorCode::InternalError).build() + } + } +} + +pub async fn delete_model_pricing(State(state): State>, cookies: Cookies, Path(id): Path) -> impl IntoResponse { + let user = match get_current_user(&state.db, &cookies).await { + Some(user) => user, + None => return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(), + }; + if !user.has_permission(&state.db, ADMIN_MODELS_EDIT).await { + return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); + } + let model = match Model::find_by_id(&state.db, &id).await { + Ok(Some(model)) => model, + Ok(None) => return ErrorBuilder::new(ErrorCode::NotFound).build(), + Err(e) => { + eprintln!("[ADMIN] Failed to load model for pricing: {e}"); + return ErrorBuilder::new(ErrorCode::InternalError).build(); + } + }; + match ModelPricing::delete_override(&state.db, &id).await { + Ok(()) => ResponseBuilder::<()>::new(ResponseBody::Empty).build(), + Err(e) => { + eprintln!("[ADMIN] Failed to delete model pricing: {e}"); + ErrorBuilder::new(ErrorCode::InternalError).build() + } + } +} + /// PATCH /api/v1/admin/models/:id pub async fn patch_model(State(state): State>, cookies: Cookies, Path(id): Path, Json(req): Json) -> impl IntoResponse { let user = match get_current_user(&state.db, &cookies).await { diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 8fc55b5..d8a2e52 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -57,6 +57,15 @@ pub fn build_router() -> Router> { .route("/api/v1/admin/teams/{id}/members", put(admin::teams::set_team_members)) .route("/api/v1/admin/teams/{id}/models", put(admin::teams::set_team_models)) .route("/api/v1/admin/teams/{id}/budget", patch(admin::teams::update_team_budget)) + // Admin Budgets + .route("/api/v1/admin/budgets", get(admin::budgets::list_budgets)) + .route("/api/v1/admin/budgets", post(admin::budgets::create_budget)) + .route("/api/v1/admin/budgets/{id}", patch(admin::budgets::update_budget)) + .route("/api/v1/admin/budgets/{id}", delete(admin::budgets::delete_budget)) + .route("/api/v1/admin/budgets/{id}/assignments", get(admin::budgets::get_budget_assignments).post(admin::budgets::assign_budget)) + .route("/api/v1/admin/budgets/assignments", delete(admin::budgets::unassign_budget)) + .route("/api/v1/admin/budgets/{id}/assignments/{assignment_id}", delete(admin::budgets::delete_assignment)) + .route("/api/v1/admin/analytics", get(admin::analytics::get_analytics)) // Admin Users .route("/api/v1/admin/users", get(admin::users::list_users)) .route("/api/v1/admin/users", post(admin::users::create_user)) @@ -70,6 +79,9 @@ pub fn build_router() -> Router> { .route("/api/v1/admin/models", get(admin::models::list_models)) .route("/api/v1/admin/models/{id}", get(admin::models::get_model)) .route("/api/v1/admin/models/{id}", patch(admin::models::patch_model)) + .route("/api/v1/admin/models/{id}/pricing", get(admin::models::get_model_pricing)) + .route("/api/v1/admin/models/{id}/pricing", put(admin::models::put_model_pricing)) + .route("/api/v1/admin/models/{id}/pricing", delete(admin::models::delete_model_pricing)) .route("/api/v1/admin/models/{id}/provider-options", get(admin::catalog::list_provider_options)) .route("/api/v1/admin/models/{id}/provider-options/refresh", post(admin::catalog::refresh_provider_options)) // Admin Config @@ -85,6 +97,8 @@ pub fn build_router() -> Router> { .route("/api/v1/auth/oauth/{provider}/callback", get(public::oauth::oauth_callback)) // Users .route("/api/v1/users/@me", get(public::users::get_me)) + .route("/api/v1/me/budget", get(public::users::get_my_budget)) + .route("/api/v1/me/analytics", get(public::users::get_my_analytics)) .route("/api/v1/users/@me/preferences", get(public::preferences::get_preferences)) .route("/api/v1/users/@me/preferences", patch(public::preferences::update_preferences)) // Workspaces diff --git a/src/routes/public/models.rs b/src/routes/public/models.rs index e2057c3..58892f1 100644 --- a/src/routes/public/models.rs +++ b/src/routes/public/models.rs @@ -4,17 +4,17 @@ use crate::config::Config; use crate::routes::public::auth::get_current_user; -use crate::types::JobState; use crate::types::catalog::{AvailabilityState, GatewayCatalogModel}; use crate::types::models::{Model, ModelListParams, ModelViewer}; use crate::types::models_configs::ModelConfig; +use crate::types::{Budget, JobState}; use crate::utils::providers::sync_endpoint_options; use crate::utils::response::{ErrorBuilder, ErrorCode, ResponseBody, ResponseBuilder}; use axum::Json; use axum::extract::{Path, Query}; use axum::{extract::State, response::IntoResponse}; use serde::Deserialize; -use std::sync::Arc; +use std::{collections::HashSet, sync::Arc}; use tower_cookies::Cookies; use uuid::Uuid; @@ -33,7 +33,7 @@ pub async fn list_models(State(state): State>, cookies: Cookies, Q }; let viewer = ModelViewer { user_id: &user.id }; - let models = match Model::list_for_user( + let mut models = match Model::list_for_user( &state.db, viewer, params.page.unwrap_or(1), @@ -52,6 +52,17 @@ pub async fn list_models(State(state): State>, cookies: Cookies, Q } }; + let blocked_ids: HashSet = match Budget::status_for_user(&state.db, &user.id).await { + Ok(status) => status.blocked_model_ids.into_iter().collect(), + Err(e) => { + eprintln!("[PUBLIC] Failed to load budget status for model list: {e}"); + HashSet::new() + } + }; + for model in &mut models.items { + model.budget_blocked = blocked_ids.contains(&model.id); + } + ResponseBuilder::new(ResponseBody::Json(models)).build() } diff --git a/src/routes/public/streaming.rs b/src/routes/public/streaming.rs index a685757..b231dd6 100644 --- a/src/routes/public/streaming.rs +++ b/src/routes/public/streaming.rs @@ -4,11 +4,11 @@ use crate::ai; use crate::routes::public::auth::get_current_user; -use crate::types::models::Model; +use crate::types::models::{Model, ModelPricing}; use crate::types::models_configs::{ModelConfig, ModelConfigViewer}; use crate::types::{ - Chat, ChatMessageResponse, ClientToolResultRequest, Message, MessagePart, RequestSettings, StreamData, StreamRequest, StreamingAssistantMessageCreate, - StreamingUserMessageCreate, Tool, ToolExecution, ToolExecutionInternal, ToolFunction, UserToolSettings, + Budget, Chat, ChatMessageResponse, ClientToolResultRequest, Message, MessagePart, RequestSettings, StreamData, StreamRequest, StreamingAssistantMessageCreate, + StreamingUserMessageCreate, Tool, ToolExecution, ToolExecutionInternal, ToolFunction, UsageEvent, UsageEventRecord, UserToolSettings, }; use crate::types::{CostDetails, JobState}; use crate::utils::tools::{HttpExecutor, ToolContext, ToolExecutor, get_builtin_executor}; @@ -26,6 +26,7 @@ use omniference::{ stream::StreamEvent, types::{ChatRequestIR, ContentPart, Message as OmniMessage, Role, ToolChoice, ToolSpec}, }; +use rust_decimal::{Decimal, prelude::FromPrimitive}; use std::{collections::HashMap, convert::Infallible, sync::Arc, time::Instant}; use tower_cookies::Cookies; use uuid::Uuid; @@ -420,6 +421,24 @@ pub async fn stream_completion(State(state): State>, cookies: Cook } } + match ModelPricing::is_free(&state.db, &model.id).await { + Ok(true) => {} + Ok(false) => match Budget::status_for_user(&state.db, &user.id).await { + Ok(status) if status.blocked_model_ids.contains(&model.id) => { + return error_stream("budget_exceeded", "Budget exceeded for this model").into_response(); + } + Ok(_) => {} + Err(e) => { + eprintln!("[STREAM] Failed to check budget status: {e}"); + return error_stream("internal_error", "Failed to check budget status").into_response(); + } + }, + Err(e) => { + eprintln!("[STREAM] Failed to check model pricing: {e}"); + return error_stream("internal_error", "Failed to check model pricing").into_response(); + } + } + let user_model_config = ModelConfig::find_for_user_by_stable_key(&state.db, ModelConfigViewer { user_id: &user.id }, &req.model_key) .await .ok() @@ -614,6 +633,7 @@ pub async fn stream_completion(State(state): State>, cookies: Cook let ir_for_stream = ir.clone(); let db = state.db.clone(); + let primary_team_id = Budget::primary_team_id(&state.db, &user.id).await.ok().flatten(); let mcp_pool = state.mcp_pool.clone(); let client_tool_pending = state.client_tool_pending.clone(); let start_time = Instant::now(); @@ -863,6 +883,10 @@ pub async fn stream_completion(State(state): State>, cookies: Cook budget_tokens: reasoning_budget_tokens.map(|b| b as i32), }; + // Cost (including any admin pricing override) is computed by + // omniference's cost middleware and delivered via the Cost event. + let final_cost_total = Decimal::from_f64(cost_details.total.unwrap_or(0.0)).unwrap_or(Decimal::ZERO); + let message = Message::create_streaming_assistant( &db, StreamingAssistantMessageCreate { @@ -882,6 +906,19 @@ pub async fn stream_completion(State(state): State>, cookies: Cook match message { Ok(msg) => { + if let Err(e) = UsageEvent::record(&db, UsageEventRecord { + user_id: &user.id, + team_id: primary_team_id, + model_id: &model.id, + provider_id: &model.provider_id, + request_type: "chat", + input_tokens: input_tokens as i32, + output_tokens: output_tokens as i32, + reasoning_tokens: reasoning_tokens as i32, + cost_total: final_cost_total, + }).await { + eprintln!("[STREAM] Failed to record usage event for message {}: {e}", msg.id); + } if !all_tool_executions.is_empty() { if let Err(e) = ToolExecution::create_for_message_batch(&db, &msg.id, &all_tool_executions).await { eprintln!("[STREAM] Failed to save tool executions for message {}: {e}", msg.id); diff --git a/src/routes/public/users.rs b/src/routes/public/users.rs index ede79f7..7ae152d 100644 --- a/src/routes/public/users.rs +++ b/src/routes/public/users.rs @@ -1,7 +1,10 @@ use crate::routes::public::auth::get_current_user; -use crate::types::JobState; +use crate::types::{AnalyticsQuery, Budget, JobState, UsageEvent}; use crate::utils::response::{ErrorBuilder, ErrorCode, ResponseBody, ResponseBuilder}; -use axum::{extract::State, response::IntoResponse}; +use axum::{ + extract::{Query, State}, + response::IntoResponse, +}; use std::sync::Arc; use tower_cookies::Cookies; @@ -26,3 +29,41 @@ pub async fn get_me(State(state): State>, cookies: Cookies) -> imp } } } + +pub async fn get_my_budget(State(state): State>, cookies: Cookies) -> impl IntoResponse { + let Some(user) = get_current_user(&state.db, &cookies).await else { + return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); + }; + + match Budget::status_for_user(&state.db, &user.id).await { + Ok(status) => ResponseBuilder::new(ResponseBody::Json(status)).build(), + Err(e) => { + eprintln!("[USERS] Failed to fetch budget status for user {}: {e}", user.id); + ErrorBuilder::new(ErrorCode::InternalError).build() + } + } +} + +pub async fn get_my_analytics(State(state): State>, cookies: Cookies, Query(params): Query) -> impl IntoResponse { + let Some(user) = get_current_user(&state.db, &cookies).await else { + return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); + }; + let group_by = params.group_by.as_deref().unwrap_or("model"); + if group_by == "day_model" { + return match UsageEvent::day_model_analytics(&state.db, params.from, params.to, Some(&user.id)).await { + Ok(rows) => ResponseBuilder::new(ResponseBody::Json(rows)).build(), + Err(e) => { + eprintln!("[ANALYTICS] Failed to load user day_model analytics: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + }; + } + match UsageEvent::analytics_for_user(&state.db, &user.id, params.from, params.to, group_by).await { + Ok(rows) => ResponseBuilder::new(ResponseBody::Json(rows)).build(), + Err(e) => { + eprintln!("[ANALYTICS] Failed to load user analytics: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + } +} + diff --git a/src/types/budgets/mod.rs b/src/types/budgets/mod.rs new file mode 100644 index 0000000..8cd96e5 --- /dev/null +++ b/src/types/budgets/mod.rs @@ -0,0 +1,39 @@ +use crate::types::BaseType; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +mod repository; +mod requests; +mod responses; +mod rows; + +pub use requests::*; +pub use responses::*; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Budget { + pub id: Uuid, + pub name: String, + pub description: Option, + pub amount: Decimal, + pub kind: String, + pub interval: String, + pub reset_strategy: String, + pub on_exceed: String, + pub is_enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone)] +pub struct EffectiveBudget { + pub budget: Budget, + pub assignment_id: Uuid, + pub team_id: Option, + pub user_id: Option, + pub assigned_at: DateTime, +} + +impl BaseType for Budget {} diff --git a/src/types/budgets/repository.rs b/src/types/budgets/repository.rs new file mode 100644 index 0000000..69aa9b6 --- /dev/null +++ b/src/types/budgets/repository.rs @@ -0,0 +1,431 @@ +use super::rows::EffectiveBudgetRow; +use super::{Budget, BudgetAssignmentInfo, BudgetAssignmentRequest, BudgetResponse, CreateBudgetRequest, EffectiveBudget, EffectiveBudgetResponse, UpdateBudgetRequest, UserBudgetStatus}; +use crate::types::BaseType; +use crate::types::axum::PaginatedResponse; +use crate::types::models::ModelPricing; +use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc}; +use rust_decimal::Decimal; +use sqlx::PgPool; +use uuid::Uuid; + +impl Budget { + fn search_pattern(search: Option<&str>) -> Option { + search.map(str::trim).filter(|s| !s.is_empty()).map(|s| format!("%{s}%")) + } + + fn budget_from_row(row: EffectiveBudgetRow) -> EffectiveBudget { + EffectiveBudget { + budget: Budget { + id: row.id, + name: row.name, + description: row.description, + amount: row.amount, + kind: row.kind, + interval: row.interval, + reset_strategy: row.reset_strategy, + on_exceed: row.on_exceed, + is_enabled: row.is_enabled, + created_at: row.created_at, + updated_at: row.updated_at, + }, + assignment_id: row.assignment_id, + team_id: row.team_id, + user_id: row.user_id, + assigned_at: row.assigned_at, + } + } + + pub async fn list(pool: &PgPool, page: i64, size: i64, search: Option<&str>) -> Result, sqlx::Error> { + let pagination = Self::pagination(page, size); + let search = Self::search_pattern(search); + let rows = sqlx::query_as::<_, Budget>( + r#" + SELECT id, name, description, amount, kind::text AS kind, interval::text AS interval, + reset_strategy::text AS reset_strategy, on_exceed::text AS on_exceed, is_enabled, created_at, updated_at + FROM budgets + WHERE ($1::text IS NULL OR name ILIKE $1) + ORDER BY name ASC + LIMIT $2 OFFSET $3 + "#, + ) + .bind(search.as_deref()) + .bind(pagination.limit) + .bind(pagination.offset) + .fetch_all(pool) + .await?; + let has_more = rows.len() > pagination.page_size; + let items = rows.into_iter().take(pagination.page_size).map(BudgetResponse::from).collect(); + Ok(PaginatedResponse { has_more, items }) + } + + pub async fn find_by_id(pool: &PgPool, id: &Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Self>( + r#" + SELECT id, name, description, amount, kind::text AS kind, interval::text AS interval, + reset_strategy::text AS reset_strategy, on_exceed::text AS on_exceed, is_enabled, created_at, updated_at + FROM budgets WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(pool) + .await + } + + pub async fn create(pool: &PgPool, req: &CreateBudgetRequest) -> Result { + sqlx::query_as::<_, Self>( + r#" + INSERT INTO budgets (name, description, amount, kind, interval, reset_strategy, on_exceed, is_enabled) + VALUES ($1, $2, $3, $4::budget_kind, $5::budget_interval, $6::budget_reset_strategy, $7::budget_on_exceed, $8) + RETURNING id, name, description, amount, kind::text AS kind, interval::text AS interval, + reset_strategy::text AS reset_strategy, on_exceed::text AS on_exceed, is_enabled, created_at, updated_at + "#, + ) + .bind(req.name.trim()) + .bind(req.description.as_deref().filter(|s| !s.trim().is_empty())) + .bind(req.amount) + .bind(req.kind.as_str()) + .bind(req.interval.as_str()) + .bind(req.reset_strategy.as_str()) + .bind(req.on_exceed.as_str()) + .bind(req.is_enabled.unwrap_or(true)) + .fetch_one(pool) + .await + } + + pub async fn update(&self, pool: &PgPool, req: &UpdateBudgetRequest) -> Result { + sqlx::query_as::<_, Self>( + r#" + UPDATE budgets + SET name = COALESCE($2, name), + description = CASE WHEN $3 THEN $4 ELSE description END, + amount = COALESCE($5, amount), + kind = COALESCE($6::budget_kind, kind), + interval = COALESCE($7::budget_interval, interval), + reset_strategy = COALESCE($8::budget_reset_strategy, reset_strategy), + on_exceed = COALESCE($9::budget_on_exceed, on_exceed), + is_enabled = COALESCE($10, is_enabled), + updated_at = NOW() + WHERE id = $1 + RETURNING id, name, description, amount, kind::text AS kind, interval::text AS interval, + reset_strategy::text AS reset_strategy, on_exceed::text AS on_exceed, is_enabled, created_at, updated_at + "#, + ) + .bind(self.id) + .bind(req.name.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(req.description.is_some()) + .bind(req.description.as_ref().and_then(|v| v.as_deref()).filter(|s| !s.trim().is_empty())) + .bind(req.amount) + .bind(req.kind.as_deref()) + .bind(req.interval.as_deref()) + .bind(req.reset_strategy.as_deref()) + .bind(req.on_exceed.as_deref()) + .bind(req.is_enabled) + .fetch_one(pool) + .await + } + + pub async fn delete(&self, pool: &PgPool) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM budgets WHERE id = $1").bind(self.id).execute(pool).await?; + Ok(()) + } + + pub async fn assign_to_team(&self, pool: &PgPool, team_id: &Uuid) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + sqlx::query( + r#" + DELETE FROM budget_assignments ba + USING budgets b + WHERE ba.budget_id = b.id + AND ba.team_id = $1 + AND b.kind = $2::budget_kind + "#, + ) + .bind(team_id) + .bind(self.kind.as_str()) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO budget_assignments (budget_id, team_id) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(self.id) + .bind(team_id) + .execute(&mut *tx) + .await?; + tx.commit().await + } + + pub async fn assign_to_user(&self, pool: &PgPool, user_id: &Uuid) -> Result<(), sqlx::Error> { + let mut tx = pool.begin().await?; + sqlx::query("DELETE FROM budget_assignments WHERE user_id = $1") + .bind(user_id) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT INTO budget_assignments (budget_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(self.id) + .bind(user_id) + .execute(&mut *tx) + .await?; + tx.commit().await + } + + pub async fn assign(&self, pool: &PgPool, req: &BudgetAssignmentRequest) -> Result<(), sqlx::Error> { + if let Some(team_id) = req.team_id { + self.assign_to_team(pool, &team_id).await + } else if let Some(user_id) = req.user_id { + self.assign_to_user(pool, &user_id).await + } else { + Ok(()) + } + } + + pub async fn unassign(pool: &PgPool, req: &BudgetAssignmentRequest) -> Result<(), sqlx::Error> { + if req.team_id.is_none() && req.user_id.is_none() { + return Ok(()); + } + sqlx::query( + r#" + DELETE FROM budget_assignments + WHERE (($1::uuid IS NOT NULL AND team_id = $1) + OR ($2::uuid IS NOT NULL AND user_id = $2)) + "#, + ) + .bind(req.team_id) + .bind(req.user_id) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn delete_assignment(pool: &PgPool, assignment_id: &Uuid) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM budget_assignments WHERE id = $1") + .bind(assignment_id) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn list_assignments(pool: &PgPool, budget_id: &Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, BudgetAssignmentInfo>( + r#" + SELECT ba.id, ba.budget_id, ba.team_id, t.name AS team_name, + ba.user_id, COALESCE(u.username, u.email) AS user_label, ba.assigned_at + FROM budget_assignments ba + LEFT JOIN teams t ON t.id = ba.team_id + LEFT JOIN users u ON u.id = ba.user_id + WHERE ba.budget_id = $1 + ORDER BY ba.assigned_at ASC + "#, + ) + .bind(budget_id) + .fetch_all(pool) + .await + } + + pub async fn budgets_for_user(pool: &PgPool, user_id: &Uuid) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, EffectiveBudgetRow>( + r#" + SELECT b.id, b.name, b.description, b.amount, b.kind::text AS kind, b.interval::text AS interval, + b.reset_strategy::text AS reset_strategy, b.on_exceed::text AS on_exceed, b.is_enabled, b.created_at, b.updated_at, + ba.id AS assignment_id, ba.team_id, ba.user_id, ba.assigned_at + FROM budget_assignments ba + JOIN budgets b ON b.id = ba.budget_id + WHERE b.is_enabled = true + AND ( + ba.user_id = $1 + OR EXISTS ( + SELECT 1 FROM team_members tm + WHERE tm.team_id = ba.team_id AND tm.user_id = $1 + ) + ) + ORDER BY b.name ASC + "#, + ) + .bind(user_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(Self::budget_from_row).collect()) + } + + pub async fn primary_team_id(pool: &PgPool, user_id: &Uuid) -> Result, sqlx::Error> { + sqlx::query_scalar( + r#" + SELECT t.id + FROM teams t + JOIN team_members tm ON tm.team_id = t.id + WHERE tm.user_id = $1 + ORDER BY t.is_default DESC, tm.assigned_at ASC + LIMIT 1 + "#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn status_for_user(pool: &PgPool, user_id: &Uuid) -> Result { + let effective = Self::budgets_for_user(pool, user_id).await?; + let mut budgets = Vec::with_capacity(effective.len()); + let mut should_block = false; + let mut should_warn = false; + for budget in effective { + let (window_start, resets_at) = Self::window(&budget); + let spent = Self::spent_in_window(pool, user_id, &budget, window_start).await?; + let remaining = (budget.budget.amount - spent).max(Decimal::ZERO); + let exhausted = spent >= budget.budget.amount; + should_block |= exhausted && budget.budget.on_exceed == "block"; + should_warn |= exhausted && budget.budget.on_exceed == "warn"; + budgets.push(EffectiveBudgetResponse { + amount: budget.budget.amount, + spent, + remaining, + window_start, + resets_at, + on_exceed: budget.budget.on_exceed.clone(), + exhausted, + assignment_id: budget.assignment_id, + team_id: budget.team_id, + user_id: budget.user_id, + budget: BudgetResponse::from(budget.budget), + }); + } + let blocked_model_ids = if should_block { ModelPricing::priced_model_ids(pool).await? } else { Vec::new() }; + let decision = if should_block { + "block" + } else if should_warn { + "warn" + } else { + "allow" + }; + Ok(UserBudgetStatus { + budgets, + decision: decision.to_string(), + blocked_model_ids, + }) + } + + async fn spent_in_window(pool: &PgPool, user_id: &Uuid, budget: &EffectiveBudget, window_start: DateTime) -> Result { + if budget.budget.kind == "pooled" { + if let Some(team_id) = budget.team_id { + return sqlx::query_scalar( + r#" + SELECT COALESCE(SUM(ue.cost_total), 0) + FROM usage_events ue + WHERE ue.created_at >= $1 + AND EXISTS ( + SELECT 1 FROM team_members tm + WHERE tm.team_id = $2 AND tm.user_id = ue.user_id + ) + "#, + ) + .bind(window_start) + .bind(team_id) + .fetch_one(pool) + .await; + } + } + sqlx::query_scalar( + r#" + SELECT COALESCE(SUM(cost_total), 0) + FROM usage_events + WHERE user_id = $1 AND created_at >= $2 + "#, + ) + .bind(user_id) + .bind(window_start) + .fetch_one(pool) + .await + } + + fn window(budget: &EffectiveBudget) -> (DateTime, Option>) { + match budget.budget.reset_strategy.as_str() { + "rolling" => Self::rolling_window(&budget.budget.interval), + "anchored" => Self::anchored_window(budget.assigned_at, &budget.budget.interval), + _ => Self::calendar_window(&budget.budget.interval), + } + } + + fn rolling_window(interval: &str) -> (DateTime, Option>) { + let now = Utc::now(); + let days = Self::interval_days(interval); + if days == 0 { + return (Utc.timestamp_opt(0, 0).single().unwrap_or(now), None); + } + (now - Duration::days(days), Some(now)) + } + + fn anchored_window(assigned_at: DateTime, interval: &str) -> (DateTime, Option>) { + let days = Self::interval_days(interval); + if days == 0 { + return (assigned_at, None); + } + let elapsed = Utc::now().signed_duration_since(assigned_at).num_days().max(0); + let periods = elapsed / days; + let start = assigned_at + Duration::days(periods * days); + (start, Some(start + Duration::days(days))) + } + + fn calendar_window(interval: &str) -> (DateTime, Option>) { + let now = Utc::now(); + let date = now.date_naive(); + let midnight = NaiveTime::from_hms_opt(0, 0, 0).unwrap_or(NaiveTime::MIN); + match interval { + "daily" => { + let start = Utc.from_utc_datetime(&date.and_time(midnight)); + (start, Some(start + Duration::days(1))) + } + "weekly" => { + let start_date = date - Duration::days(i64::from(date.weekday().num_days_from_monday())); + let start = Utc.from_utc_datetime(&start_date.and_time(midnight)); + (start, Some(start + Duration::days(7))) + } + "monthly" => { + let start_date = date.with_day(1).unwrap_or(date); + let start = Utc.from_utc_datetime(&start_date.and_time(midnight)); + let next = if start_date.month() == 12 { + start_date.with_year(start_date.year() + 1).and_then(|d| d.with_month(1)) + } else { + start_date.with_month(start_date.month() + 1) + } + .unwrap_or(start_date); + (start, Some(Utc.from_utc_datetime(&next.and_time(midnight)))) + } + _ => (Utc.timestamp_opt(0, 0).single().unwrap_or(now), None), + } + } + + fn interval_days(interval: &str) -> i64 { + match interval { + "daily" => 1, + "weekly" => 7, + "monthly" => 30, + _ => 0, + } + } + + pub async fn assignments(pool: &PgPool, budget_id: &Uuid) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, EffectiveBudgetRow>( + r#" + SELECT b.id, b.name, b.description, b.amount, b.kind::text AS kind, b.interval::text AS interval, + b.reset_strategy::text AS reset_strategy, b.on_exceed::text AS on_exceed, b.is_enabled, b.created_at, b.updated_at, + ba.id AS assignment_id, ba.team_id, ba.user_id, ba.assigned_at + FROM budget_assignments ba + JOIN budgets b ON b.id = ba.budget_id + WHERE b.id = $1 + ORDER BY ba.assigned_at DESC + "#, + ) + .bind(budget_id) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(Self::budget_from_row).collect()) + } + + pub async fn replace_team_budget_from_legacy(pool: &PgPool, team_id: &Uuid, budget_id: Option) -> Result<(), sqlx::Error> { + let Some(budget_id) = budget_id else { + sqlx::query("DELETE FROM budget_assignments WHERE team_id = $1").bind(team_id).execute(pool).await?; + return Ok(()); + }; + if let Some(budget) = Self::find_by_id(pool, &budget_id).await? { + budget.assign_to_team(pool, team_id).await?; + } + Ok(()) + } +} diff --git a/src/types/budgets/requests.rs b/src/types/budgets/requests.rs new file mode 100644 index 0000000..f83cd19 --- /dev/null +++ b/src/types/budgets/requests.rs @@ -0,0 +1,40 @@ +use rust_decimal::Decimal; +use serde::Deserialize; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +pub struct ListBudgetsQuery { + pub page: Option, + pub size: Option, + pub search: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateBudgetRequest { + pub name: String, + pub description: Option, + pub amount: Decimal, + pub kind: String, + pub interval: String, + pub reset_strategy: String, + pub on_exceed: String, + pub is_enabled: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateBudgetRequest { + pub name: Option, + pub description: Option>, + pub amount: Option, + pub kind: Option, + pub interval: Option, + pub reset_strategy: Option, + pub on_exceed: Option, + pub is_enabled: Option, +} + +#[derive(Debug, Deserialize)] +pub struct BudgetAssignmentRequest { + pub team_id: Option, + pub user_id: Option, +} diff --git a/src/types/budgets/responses.rs b/src/types/budgets/responses.rs new file mode 100644 index 0000000..ec1b0f8 --- /dev/null +++ b/src/types/budgets/responses.rs @@ -0,0 +1,93 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::Serialize; +use uuid::Uuid; + +use super::{Budget, EffectiveBudget}; + +#[derive(Debug, Serialize)] +pub struct BudgetResponse { + pub id: Uuid, + pub name: String, + pub description: Option, + pub amount: Decimal, + pub kind: String, + pub interval: String, + pub reset_strategy: String, + pub on_exceed: String, + pub is_enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct BudgetAssignmentResponse { + pub id: Uuid, + pub budget_id: Uuid, + pub team_id: Option, + pub user_id: Option, + pub assigned_at: DateTime, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct BudgetAssignmentInfo { + pub id: Uuid, + pub budget_id: Uuid, + pub team_id: Option, + pub team_name: Option, + pub user_id: Option, + pub user_label: Option, + pub assigned_at: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct EffectiveBudgetResponse { + pub budget: BudgetResponse, + pub assignment_id: Uuid, + pub team_id: Option, + pub user_id: Option, + pub amount: Decimal, + pub spent: Decimal, + pub remaining: Decimal, + pub window_start: DateTime, + pub resets_at: Option>, + pub on_exceed: String, + pub exhausted: bool, +} + +#[derive(Debug, Serialize)] +pub struct UserBudgetStatus { + pub budgets: Vec, + pub decision: String, + pub blocked_model_ids: Vec, +} + +impl From for BudgetResponse { + fn from(budget: Budget) -> Self { + Self { + id: budget.id, + name: budget.name, + description: budget.description, + amount: budget.amount, + kind: budget.kind, + interval: budget.interval, + reset_strategy: budget.reset_strategy, + on_exceed: budget.on_exceed, + is_enabled: budget.is_enabled, + created_at: budget.created_at, + updated_at: budget.updated_at, + } + } +} + +impl From<&EffectiveBudget> for BudgetAssignmentResponse { + fn from(budget: &EffectiveBudget) -> Self { + Self { + id: budget.assignment_id, + budget_id: budget.budget.id, + team_id: budget.team_id, + user_id: budget.user_id, + assigned_at: budget.assigned_at, + } + } +} diff --git a/src/types/budgets/rows.rs b/src/types/budgets/rows.rs new file mode 100644 index 0000000..9940a2f --- /dev/null +++ b/src/types/budgets/rows.rs @@ -0,0 +1,22 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use uuid::Uuid; + +#[derive(sqlx::FromRow)] +pub(super) struct EffectiveBudgetRow { + pub id: Uuid, + pub name: String, + pub description: Option, + pub amount: Decimal, + pub kind: String, + pub interval: String, + pub reset_strategy: String, + pub on_exceed: String, + pub is_enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub assignment_id: Uuid, + pub team_id: Option, + pub user_id: Option, + pub assigned_at: DateTime, +} diff --git a/src/types/mod.rs b/src/types/mod.rs index e1d5194..6628680 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -27,6 +27,7 @@ pub struct Pagination { pub mod auth; pub mod axum; pub mod base; +pub mod budgets; pub mod catalog; pub mod chat; pub mod i18n; @@ -41,10 +42,12 @@ pub mod roles; pub mod state; pub mod teams; pub mod tools; +pub mod usage; pub mod user; pub use auth::*; pub use base::*; +pub use budgets::*; pub use chat::*; pub use i18n::*; pub use images::*; @@ -54,4 +57,5 @@ pub use roles::*; pub use state::*; pub use teams::*; pub use tools::*; +pub use usage::*; pub use user::*; diff --git a/src/types/models/mod.rs b/src/types/models/mod.rs index 41e6129..fdda574 100644 --- a/src/types/models/mod.rs +++ b/src/types/models/mod.rs @@ -1,5 +1,6 @@ use crate::types::BaseType; use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::types::Json; @@ -51,4 +52,14 @@ pub struct ModelSyncSummary { pub removed: usize, } +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct ModelPricing { + pub model_id: Uuid, + pub reported_input: Option, + pub reported_output: Option, + pub override_pricing: Option, + pub effective_input: Decimal, + pub effective_output: Decimal, +} + impl BaseType for Model {} diff --git a/src/types/models/repository.rs b/src/types/models/repository.rs index 3e9671f..e9d2f50 100644 --- a/src/types/models/repository.rs +++ b/src/types/models/repository.rs @@ -1,5 +1,5 @@ use super::rows::{ModelDetailedRow, ModelListAdminRow, ModelListPublicRow}; -use super::{Model, ModelDetailed, ModelListAdmin, ModelListPublic, ModelSyncInput, ModelSyncSummary, ModelViewer}; +use super::{Model, ModelDetailed, ModelListAdmin, ModelListPublic, ModelPricing, ModelSyncInput, ModelSyncSummary, ModelViewer}; use crate::types::BaseType; use crate::types::PolicyResolver; use crate::types::axum::PaginatedResponse; @@ -511,3 +511,122 @@ impl Model { Ok(row.map(|r| (r.display_name, r.model_id))) } } + +impl ModelPricing { + pub async fn effective(pool: &sqlx::PgPool, model_id: &Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, Self>( + r#" + SELECT + m.id AS model_id, + MIN(gmpo.price_input)::numeric AS reported_input, + MIN(gmpo.price_output)::numeric AS reported_output, + mpo.pricing AS override_pricing, + COALESCE((mpo.pricing->>'input')::numeric, MIN(gmpo.price_input)::numeric, 0) AS effective_input, + COALESCE((mpo.pricing->>'output')::numeric, MIN(gmpo.price_output)::numeric, 0) AS effective_output + FROM models m + LEFT JOIN gateway_catalog_models gcm ON gcm.local_model_id = m.id + LEFT JOIN gateway_model_provider_options gmpo ON gmpo.catalog_model_id = gcm.id + LEFT JOIN model_pricing_overrides mpo ON mpo.model_id = m.id + WHERE m.id = $1 + GROUP BY m.id, mpo.pricing + "#, + ) + .bind(model_id) + .fetch_optional(pool) + .await + } + + pub async fn list_with_overrides(pool: &sqlx::PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, Self>( + r#" + SELECT + m.id AS model_id, + MIN(gmpo.price_input)::numeric AS reported_input, + MIN(gmpo.price_output)::numeric AS reported_output, + mpo.pricing AS override_pricing, + COALESCE((mpo.pricing->>'input')::numeric, MIN(gmpo.price_input)::numeric, 0) AS effective_input, + COALESCE((mpo.pricing->>'output')::numeric, MIN(gmpo.price_output)::numeric, 0) AS effective_output + FROM models m + LEFT JOIN gateway_catalog_models gcm ON gcm.local_model_id = m.id + LEFT JOIN gateway_model_provider_options gmpo ON gmpo.catalog_model_id = gcm.id + LEFT JOIN model_pricing_overrides mpo ON mpo.model_id = m.id + GROUP BY m.id, mpo.pricing + ORDER BY m.display_name ASC + "#, + ) + .fetch_all(pool) + .await + } + + pub async fn upsert_override(pool: &sqlx::PgPool, model_id: &Uuid, pricing: &Value) -> Result { + sqlx::query( + r#" + INSERT INTO model_pricing_overrides (model_id, pricing) + VALUES ($1, $2) + ON CONFLICT (model_id) DO UPDATE + SET pricing = EXCLUDED.pricing, + updated_at = NOW() + "#, + ) + .bind(model_id) + .bind(pricing) + .execute(pool) + .await?; + Self::effective(pool, model_id).await?.ok_or(sqlx::Error::RowNotFound) + } + + pub async fn delete_override(pool: &sqlx::PgPool, model_id: &Uuid) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM model_pricing_overrides WHERE model_id = $1") + .bind(model_id) + .execute(pool) + .await?; + Ok(()) + } + + /// All pricing overrides keyed by the model's string identifier, for pushing + /// into the omniference catalog as programmatic overrides. + pub async fn all_overrides(pool: &sqlx::PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, (String, Value)>( + r#" + SELECT m.model_id, mpo.pricing + FROM model_pricing_overrides mpo + JOIN models m ON m.id = mpo.model_id + "#, + ) + .fetch_all(pool) + .await + } + + pub async fn is_free(pool: &sqlx::PgPool, model_id: &Uuid) -> Result { + let Some(pricing) = Self::effective(pool, model_id).await? else { + return Ok(false); + }; + let override_free = pricing.override_pricing.is_some() && pricing.effective_input.is_zero() && pricing.effective_output.is_zero(); + let reported_free = pricing.override_pricing.is_none() + && pricing.reported_input.is_some() + && pricing.reported_output.is_some() + && pricing.effective_input.is_zero() + && pricing.effective_output.is_zero(); + Ok(override_free || reported_free) + } + + pub async fn priced_model_ids(pool: &sqlx::PgPool) -> Result, sqlx::Error> { + sqlx::query_scalar( + r#" + SELECT m.id + FROM models m + LEFT JOIN model_pricing_overrides mpo ON mpo.model_id = m.id + LEFT JOIN gateway_catalog_models gcm ON gcm.local_model_id = m.id + LEFT JOIN gateway_model_provider_options gmpo ON gmpo.catalog_model_id = gcm.id + GROUP BY m.id, mpo.pricing + HAVING CASE + WHEN mpo.pricing IS NOT NULL THEN (mpo.pricing->>'input')::numeric > 0 OR (mpo.pricing->>'output')::numeric > 0 + WHEN MIN(gmpo.price_input) IS NOT NULL AND MIN(gmpo.price_output) IS NOT NULL THEN MIN(gmpo.price_input) > 0 OR MIN(gmpo.price_output) > 0 + ELSE TRUE + END + "#, + ) + .fetch_all(pool) + .await + } +} diff --git a/src/types/models/requests.rs b/src/types/models/requests.rs index 88f5470..a86ba0d 100644 --- a/src/types/models/requests.rs +++ b/src/types/models/requests.rs @@ -34,3 +34,8 @@ pub struct AdminModelPatchBody { pub reasoning_effort: Option>, pub reasoning_budget_tokens: Option>, } + +#[derive(Debug, Deserialize)] +pub struct ModelPricingOverrideRequest { + pub pricing: omniference::catalog::ModelPricing, +} diff --git a/src/types/models/responses.rs b/src/types/models/responses.rs index 6007b27..71231da 100644 --- a/src/types/models/responses.rs +++ b/src/types/models/responses.rs @@ -24,6 +24,7 @@ pub struct ModelListPublic { pub provider: ProviderSlim, pub icon: Option, pub is_favorite: bool, + pub budget_blocked: bool, } #[derive(Debug, Serialize)] diff --git a/src/types/models/rows.rs b/src/types/models/rows.rs index 44c51b4..ed9057e 100644 --- a/src/types/models/rows.rs +++ b/src/types/models/rows.rs @@ -88,6 +88,7 @@ impl From for ModelListPublic { }, icon: row.icon, is_favorite: row.is_favorite, + budget_blocked: false, } } } diff --git a/src/types/permissions/mod.rs b/src/types/permissions/mod.rs index ab4bc4b..4189676 100644 --- a/src/types/permissions/mod.rs +++ b/src/types/permissions/mod.rs @@ -8,6 +8,9 @@ pub mod consts { pub const ADMIN_USERS_EDIT: &str = "admin.users.edit"; pub const ADMIN_TEAMS_VIEW: &str = "admin.teams.view"; pub const ADMIN_TEAMS_EDIT: &str = "admin.teams.edit"; + pub const ADMIN_BUDGETS_VIEW: &str = "admin.budgets.view"; + pub const ADMIN_BUDGETS_EDIT: &str = "admin.budgets.edit"; + pub const ADMIN_ANALYTICS_VIEW: &str = "admin.analytics.view"; pub const ADMIN_CONFIG_VIEW: &str = "admin.config.view"; pub const ADMIN_CONFIG_EDIT: &str = "admin.config.edit"; pub const ADMIN_TOOLS_VIEW: &str = "admin.tools.view"; diff --git a/src/types/teams/repository.rs b/src/types/teams/repository.rs index 2664966..64696ef 100644 --- a/src/types/teams/repository.rs +++ b/src/types/teams/repository.rs @@ -2,8 +2,8 @@ use super::{ CreateTeamRequest, Team, TeamDetailedResponse, TeamMemberResponse, TeamModelAccessResponse, TeamResponse, TeamSummaryResponse, UpdateTeamBudgetRequest, UpdateTeamMembersRequest, UpdateTeamModelsRequest, UpdateTeamRequest, }; -use crate::types::BaseType; use crate::types::axum::PaginatedResponse; +use crate::types::{BaseType, Budget}; use sqlx::{PgPool, Postgres, QueryBuilder}; use std::collections::HashMap; use uuid::Uuid; @@ -183,7 +183,7 @@ impl Team { } pub async fn update_budget(&self, pool: &PgPool, req: &UpdateTeamBudgetRequest) -> Result { - sqlx::query_as::<_, Self>( + let updated = sqlx::query_as::<_, Self>( r#" UPDATE teams SET budget_id = $2, @@ -195,7 +195,9 @@ impl Team { .bind(self.id) .bind(req.budget_id) .fetch_one(pool) - .await + .await?; + Budget::replace_team_budget_from_legacy(pool, &self.id, req.budget_id).await?; + Ok(updated) } pub async fn delete(&self, pool: &PgPool) -> Result { diff --git a/src/types/usage/mod.rs b/src/types/usage/mod.rs new file mode 100644 index 0000000..d83d1e5 --- /dev/null +++ b/src/types/usage/mod.rs @@ -0,0 +1,63 @@ +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +mod repository; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct UsageEvent { + pub id: Uuid, + pub user_id: Uuid, + pub team_id: Option, + pub model_id: Option, + pub provider_id: Option, + pub request_type: String, + pub input_tokens: i32, + pub output_tokens: i32, + pub reasoning_tokens: i32, + pub cost_total: Decimal, + pub created_at: DateTime, +} + +pub struct UsageEventRecord<'a> { + pub user_id: &'a Uuid, + pub team_id: Option, + pub model_id: &'a Uuid, + pub provider_id: &'a Uuid, + pub request_type: &'a str, + pub input_tokens: i32, + pub output_tokens: i32, + pub reasoning_tokens: i32, + pub cost_total: Decimal, +} + +#[derive(Debug, Deserialize)] +pub struct AnalyticsQuery { + pub from: Option>, + pub to: Option>, + pub group_by: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct AnalyticsRow { + pub id: Option, + pub label: String, + pub input_tokens: i64, + pub output_tokens: i64, + pub reasoning_tokens: i64, + pub cost_total: Decimal, + pub request_count: i64, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct AnalyticsDayModelRow { + pub day: String, + pub model_id: Option, + pub model_name: String, + pub input_tokens: i64, + pub output_tokens: i64, + pub reasoning_tokens: i64, + pub cost_total: Decimal, + pub request_count: i64, +} diff --git a/src/types/usage/repository.rs b/src/types/usage/repository.rs new file mode 100644 index 0000000..3778bf2 --- /dev/null +++ b/src/types/usage/repository.rs @@ -0,0 +1,173 @@ +use super::{AnalyticsDayModelRow, AnalyticsRow, UsageEvent, UsageEventRecord}; +use chrono::{DateTime, Duration, Utc}; +use sqlx::PgPool; +use uuid::Uuid; + +impl UsageEvent { + pub async fn record(pool: &PgPool, params: UsageEventRecord<'_>) -> Result { + sqlx::query_as::<_, Self>( + r#" + INSERT INTO usage_events ( + user_id, team_id, model_id, provider_id, request_type, + input_tokens, output_tokens, reasoning_tokens, cost_total + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, user_id, team_id, model_id, provider_id, request_type, + input_tokens, output_tokens, reasoning_tokens, cost_total, created_at + "#, + ) + .bind(params.user_id) + .bind(params.team_id) + .bind(params.model_id) + .bind(params.provider_id) + .bind(params.request_type) + .bind(params.input_tokens) + .bind(params.output_tokens) + .bind(params.reasoning_tokens) + .bind(params.cost_total) + .fetch_one(pool) + .await + } + + pub async fn analytics(pool: &PgPool, from: Option>, to: Option>, group_by: &str) -> Result, sqlx::Error> { + let end = to.unwrap_or_else(Utc::now); + let start = from.unwrap_or(end - Duration::days(30)); + match group_by { + "user" => Self::by_user(pool, start, end).await, + "team" => Self::by_team(pool, start, end).await, + "day" => Self::by_day(pool, start, end, None).await, + _ => Self::by_model(pool, start, end, None).await, + } + } + + pub async fn analytics_for_user(pool: &PgPool, user_id: &Uuid, from: Option>, to: Option>, group_by: &str) -> Result, sqlx::Error> { + let end = to.unwrap_or_else(Utc::now); + let start = from.unwrap_or(end - Duration::days(30)); + match group_by { + "day" => Self::by_day(pool, start, end, Some(user_id)).await, + _ => Self::by_model(pool, start, end, Some(user_id)).await, + } + } + + pub async fn day_model_analytics(pool: &PgPool, from: Option>, to: Option>, user_id: Option<&Uuid>) -> Result, sqlx::Error> { + let end = to.unwrap_or_else(Utc::now); + let start = from.unwrap_or(end - Duration::days(30)); + Self::by_day_by_model(pool, start, end, user_id).await + } + + async fn by_model(pool: &PgPool, start: DateTime, end: DateTime, user_id: Option<&Uuid>) -> Result, sqlx::Error> { + sqlx::query_as::<_, AnalyticsRow>( + r#" + SELECT m.id, m.display_name AS label, + COALESCE(SUM(ue.input_tokens), 0)::bigint AS input_tokens, + COALESCE(SUM(ue.output_tokens), 0)::bigint AS output_tokens, + COALESCE(SUM(ue.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(SUM(ue.cost_total), 0)::numeric AS cost_total, + COUNT(*)::bigint AS request_count + FROM usage_events ue + JOIN models m ON m.id = ue.model_id + WHERE ue.created_at >= $1 AND ue.created_at <= $2 + AND ($3::uuid IS NULL OR ue.user_id = $3) + GROUP BY m.id, m.display_name + ORDER BY cost_total DESC + "#, + ) + .bind(start) + .bind(end) + .bind(user_id) + .fetch_all(pool) + .await + } + + async fn by_user(pool: &PgPool, start: DateTime, end: DateTime) -> Result, sqlx::Error> { + sqlx::query_as::<_, AnalyticsRow>( + r#" + SELECT u.id, COALESCE(u.username, u.email) AS label, + COALESCE(SUM(ue.input_tokens), 0)::bigint AS input_tokens, + COALESCE(SUM(ue.output_tokens), 0)::bigint AS output_tokens, + COALESCE(SUM(ue.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(SUM(ue.cost_total), 0)::numeric AS cost_total, + COUNT(*)::bigint AS request_count + FROM usage_events ue + JOIN users u ON u.id = ue.user_id + WHERE ue.created_at >= $1 AND ue.created_at <= $2 + GROUP BY u.id, u.username, u.email + ORDER BY cost_total DESC + "#, + ) + .bind(start) + .bind(end) + .fetch_all(pool) + .await + } + + async fn by_team(pool: &PgPool, start: DateTime, end: DateTime) -> Result, sqlx::Error> { + sqlx::query_as::<_, AnalyticsRow>( + r#" + SELECT t.id, COALESCE(t.name, '') AS label, + COALESCE(SUM(ue.input_tokens), 0)::bigint AS input_tokens, + COALESCE(SUM(ue.output_tokens), 0)::bigint AS output_tokens, + COALESCE(SUM(ue.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(SUM(ue.cost_total), 0)::numeric AS cost_total, + COUNT(*)::bigint AS request_count + FROM usage_events ue + LEFT JOIN teams t ON t.id = ue.team_id + WHERE ue.created_at >= $1 AND ue.created_at <= $2 + GROUP BY t.id, t.name + ORDER BY cost_total DESC + "#, + ) + .bind(start) + .bind(end) + .fetch_all(pool) + .await + } + + async fn by_day(pool: &PgPool, start: DateTime, end: DateTime, user_id: Option<&Uuid>) -> Result, sqlx::Error> { + sqlx::query_as::<_, AnalyticsRow>( + r#" + SELECT NULL::uuid AS id, to_char(date_trunc('day', created_at), 'YYYY-MM-DD') AS label, + COALESCE(SUM(input_tokens), 0)::bigint AS input_tokens, + COALESCE(SUM(output_tokens), 0)::bigint AS output_tokens, + COALESCE(SUM(reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(SUM(cost_total), 0)::numeric AS cost_total, + COUNT(*)::bigint AS request_count + FROM usage_events + WHERE created_at >= $1 AND created_at <= $2 + AND ($3::uuid IS NULL OR user_id = $3) + GROUP BY date_trunc('day', created_at) + ORDER BY label ASC + "#, + ) + .bind(start) + .bind(end) + .bind(user_id) + .fetch_all(pool) + .await + } + + async fn by_day_by_model(pool: &PgPool, start: DateTime, end: DateTime, user_id: Option<&Uuid>) -> Result, sqlx::Error> { + sqlx::query_as::<_, AnalyticsDayModelRow>( + r#" + SELECT to_char(date_trunc('day', ue.created_at), 'YYYY-MM-DD') AS day, + m.id AS model_id, m.display_name AS model_name, + COALESCE(SUM(ue.input_tokens), 0)::bigint AS input_tokens, + COALESCE(SUM(ue.output_tokens), 0)::bigint AS output_tokens, + COALESCE(SUM(ue.reasoning_tokens), 0)::bigint AS reasoning_tokens, + COALESCE(SUM(ue.cost_total), 0)::numeric AS cost_total, + COUNT(*)::bigint AS request_count + FROM usage_events ue + JOIN models m ON m.id = ue.model_id + WHERE ue.created_at >= $1 AND ue.created_at <= $2 + AND ($3::uuid IS NULL OR ue.user_id = $3) + GROUP BY date_trunc('day', ue.created_at), m.id, m.display_name + ORDER BY day ASC, cost_total DESC + "#, + ) + .bind(start) + .bind(end) + .bind(user_id) + .fetch_all(pool) + .await + } +} diff --git a/src/utils/providers.rs b/src/utils/providers.rs index 5ad4f68..7499f74 100644 --- a/src/utils/providers.rs +++ b/src/utils/providers.rs @@ -40,12 +40,12 @@ pub async fn sync_provider_models(pool: &PgPool, provider: &Provider) -> Result< } } - let all_discovered = match engine_service.discover_models_for_provider(&provider.name).await { + let all_discovered = match engine_service.discover_models().await { Ok(models) => models, Err(e) => return Err(format!("Discovery failed: {e}")), }; - let discovered = all_discovered; + let discovered: Vec<_> = all_discovered.into_iter().filter(|m| m.provider_name == provider.name).collect(); let mut sync_models = Vec::with_capacity(discovered.len()); for model in discovered { From efe0bbc47641d92a7871adeecec8c54b13d72755 Mon Sep 17 00:00:00 2001 From: Henrik-3 Date: Sun, 5 Jul 2026 22:59:03 +0200 Subject: [PATCH 02/14] Fix budget race conditions and optimize spend calculation - Add PostgreSQL advisory lock for budget checks in streaming to prevent race conditions - Batch budget spend queries using UNNEST instead of N+1 queries per budget - Calculate usage costs locally from pricing overrides instead of trusting provider-reported costs - Fix budget assignment deletion to require budget_id for safety - Fix analytics dashboard cost change percentage display logic - Escape LIKE patterns in budget search to prevent injection --- .../settings/AnalyticsDashboard.vue | 25 +++- src/routes/admin/budgets.rs | 4 +- src/routes/public/streaming.rs | 62 ++++++--- src/types/budgets/repository.rs | 119 ++++++++++++------ src/types/models/repository.rs | 28 +++++ 5 files changed, 180 insertions(+), 58 deletions(-) diff --git a/frontend/components/settings/AnalyticsDashboard.vue b/frontend/components/settings/AnalyticsDashboard.vue index f26f9a9..7b40e61 100644 --- a/frontend/components/settings/AnalyticsDashboard.vue +++ b/frontend/components/settings/AnalyticsDashboard.vue @@ -52,7 +52,7 @@

{{ store.getTranslation('settings.analytics.total_cost') }}

{{ formatMoney(totalCost) }}

-

+

{{ costChange >= 0 ? '+' : '' }}{{ costChange.toFixed(1) }}% @@ -196,7 +196,7 @@ {{ model.label }}

- + {{ model.pct >= 0 ? '↑' : '↓' }} {{ Math.abs(model.pct).toFixed(0) }}% @@ -508,14 +508,33 @@ const userBarData = computed(() => ); const trendingModels = computed(() => { + const days = [...new Set(byDayModel.value.map(row => row.day))].sort(); + const mid = Math.floor(days.length / 2); + const firstDays = new Set(days.slice(0, mid)); + const secondDays = new Set(days.slice(mid)); + const spendByModel = new Map(); + for (const row of byDayModel.value) { + const spend = spendByModel.get(row.model_name) ?? {first: 0, second: 0}; + if (firstDays.has(row.day)) { + spend.first += Number(row.cost_total); + } else if (secondDays.has(row.day)) { + spend.second += Number(row.cost_total); + } + spendByModel.set(row.model_name, spend); + } return byModel.value.slice(0, 8).map((model, i) => ({ label: model.label, color: CHART_COLORS[i % CHART_COLORS.length], - pct: Math.random() * 200 - 50, + pct: trendPercent(spendByModel.get(model.label)), cost: Number(model.cost_total), })); }); +function trendPercent(spend: {first: number; second: number} | undefined): number { + if (!spend || spend.first === 0) return 0; + return ((spend.second - spend.first) / spend.first) * 100; +} + function getMetricValue(row: AnalyticsDayModelRow): number { switch (exploreMetric.value) { case 'cost': return Number(row.cost_total); diff --git a/src/routes/admin/budgets.rs b/src/routes/admin/budgets.rs index 59c41db..4743a40 100644 --- a/src/routes/admin/budgets.rs +++ b/src/routes/admin/budgets.rs @@ -137,14 +137,14 @@ pub async fn get_budget_assignments(State(state): State>, cookies: } } -pub async fn delete_assignment(State(state): State>, cookies: Cookies, Path((_budget_id, assignment_id)): Path<(Uuid, Uuid)>) -> impl IntoResponse { +pub async fn delete_assignment(State(state): State>, cookies: Cookies, Path((budget_id, assignment_id)): Path<(Uuid, Uuid)>) -> impl IntoResponse { let Some(user) = get_current_user(&state.db, &cookies).await else { return ErrorBuilder::new(ErrorCode::NotAuthenticated).build(); }; if !user.has_permission(&state.db, ADMIN_BUDGETS_EDIT).await { return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); } - match Budget::delete_assignment(&state.db, &assignment_id).await { + match Budget::delete_assignment(&state.db, &budget_id, &assignment_id).await { Ok(()) => (StatusCode::NO_CONTENT, "").into_response(), Err(e) => { eprintln!("[BUDGETS] Failed to delete assignment: {e}"); diff --git a/src/routes/public/streaming.rs b/src/routes/public/streaming.rs index b231dd6..4125835 100644 --- a/src/routes/public/streaming.rs +++ b/src/routes/public/streaming.rs @@ -38,6 +38,15 @@ fn error_stream(code: impl Into, message: impl Into) -> Sse(Event::default().data(data)) })).keep_alive(KeepAlive::default()) } +async fn acquire_budget_lock(pool: &sqlx::PgPool, user_id: &Uuid) -> Result, sqlx::Error> { + let mut tx = pool.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text, 0))") + .bind(user_id) + .execute(&mut *tx) + .await?; + Ok(tx) +} + /// Interpolate `{{variable}}` placeholders in a system prompt string. /// /// Supported variables: @@ -384,7 +393,7 @@ async fn get_omni_messages(db: &sqlx::PgPool, messages: Vec) -> Vec>, cookies: Cookies, Path(chat_id): Path, Json(req): Json) -> impl IntoResponse { - eprint!("[STREAM] Starting stream completion"); + eprintln!("[STREAM] Starting stream completion"); let Some(user) = get_current_user(&state.db, &cookies).await else { return error_stream("not_authenticated", "Authentication required").into_response(); @@ -421,18 +430,30 @@ pub async fn stream_completion(State(state): State>, cookies: Cook } } + let mut budget_lock = None; match ModelPricing::is_free(&state.db, &model.id).await { Ok(true) => {} - Ok(false) => match Budget::status_for_user(&state.db, &user.id).await { - Ok(status) if status.blocked_model_ids.contains(&model.id) => { - return error_stream("budget_exceeded", "Budget exceeded for this model").into_response(); - } - Ok(_) => {} - Err(e) => { - eprintln!("[STREAM] Failed to check budget status: {e}"); - return error_stream("internal_error", "Failed to check budget status").into_response(); + Ok(false) => { + let lock = match acquire_budget_lock(&state.db, &user.id).await { + Ok(lock) => lock, + Err(e) => { + eprintln!("[STREAM] Failed to acquire budget lock: {e}"); + return error_stream("internal_error", "Failed to check budget status").into_response(); + } + }; + match Budget::status_for_user(&state.db, &user.id).await { + Ok(status) if status.blocked_model_ids.contains(&model.id) => { + return error_stream("budget_exceeded", "Budget exceeded for this model").into_response(); + } + Ok(_) => { + budget_lock = Some(lock); + } + Err(e) => { + eprintln!("[STREAM] Failed to check budget status: {e}"); + return error_stream("internal_error", "Failed to check budget status").into_response(); + } } - }, + } Err(e) => { eprintln!("[STREAM] Failed to check model pricing: {e}"); return error_stream("internal_error", "Failed to check model pricing").into_response(); @@ -633,13 +654,13 @@ pub async fn stream_completion(State(state): State>, cookies: Cook let ir_for_stream = ir.clone(); let db = state.db.clone(); - let primary_team_id = Budget::primary_team_id(&state.db, &user.id).await.ok().flatten(); let mcp_pool = state.mcp_pool.clone(); let client_tool_pending = state.client_tool_pending.clone(); let start_time = Instant::now(); let reasoning_effort = req.reasoning_effort.clone(); let reasoning_budget_tokens = req.reasoning_budget_tokens; let stream_request_settings = request_settings.clone(); + let mut budget_lock = budget_lock; let sse_stream = async_stream::stream! { eprintln!("[STREAM] Stream generator started"); @@ -883,9 +904,15 @@ pub async fn stream_completion(State(state): State>, cookies: Cook budget_tokens: reasoning_budget_tokens.map(|b| b as i32), }; - // Cost (including any admin pricing override) is computed by - // omniference's cost middleware and delivered via the Cost event. - let final_cost_total = Decimal::from_f64(cost_details.total.unwrap_or(0.0)).unwrap_or(Decimal::ZERO); + let provider_cost_total = Decimal::from_f64(cost_details.total.unwrap_or(0.0)).unwrap_or(Decimal::ZERO); + let final_cost_total = match ModelPricing::usage_cost(&db, &model.id, input_tokens as i32, output_tokens as i32, reasoning_tokens as i32).await { + Ok(Some(cost)) => cost, + Ok(None) => provider_cost_total, + Err(e) => { + eprintln!("[STREAM] Failed to compute local usage cost for model {}: {e}", model.id); + provider_cost_total + } + }; let message = Message::create_streaming_assistant( &db, @@ -908,7 +935,7 @@ pub async fn stream_completion(State(state): State>, cookies: Cook Ok(msg) => { if let Err(e) = UsageEvent::record(&db, UsageEventRecord { user_id: &user.id, - team_id: primary_team_id, + team_id: Budget::primary_team_id(&db, &user.id).await.ok().flatten(), model_id: &model.id, provider_id: &model.provider_id, request_type: "chat", @@ -919,6 +946,11 @@ pub async fn stream_completion(State(state): State>, cookies: Cook }).await { eprintln!("[STREAM] Failed to record usage event for message {}: {e}", msg.id); } + if let Some(lock) = budget_lock.take() { + if let Err(e) = lock.commit().await { + eprintln!("[STREAM] Failed to release budget lock for message {}: {e}", msg.id); + } + } if !all_tool_executions.is_empty() { if let Err(e) = ToolExecution::create_for_message_batch(&db, &msg.id, &all_tool_executions).await { eprintln!("[STREAM] Failed to save tool executions for message {}: {e}", msg.id); diff --git a/src/types/budgets/repository.rs b/src/types/budgets/repository.rs index 69aa9b6..c3211fd 100644 --- a/src/types/budgets/repository.rs +++ b/src/types/budgets/repository.rs @@ -1,16 +1,30 @@ use super::rows::EffectiveBudgetRow; -use super::{Budget, BudgetAssignmentInfo, BudgetAssignmentRequest, BudgetResponse, CreateBudgetRequest, EffectiveBudget, EffectiveBudgetResponse, UpdateBudgetRequest, UserBudgetStatus}; +use super::{ + Budget, BudgetAssignmentInfo, BudgetAssignmentRequest, BudgetResponse, CreateBudgetRequest, EffectiveBudget, EffectiveBudgetResponse, UpdateBudgetRequest, + UserBudgetStatus, +}; use crate::types::BaseType; use crate::types::axum::PaginatedResponse; use crate::types::models::ModelPricing; use chrono::{DateTime, Datelike, Duration, NaiveTime, TimeZone, Utc}; use rust_decimal::Decimal; use sqlx::PgPool; +use std::collections::HashMap; use uuid::Uuid; +#[derive(sqlx::FromRow)] +struct BudgetSpentRow { + assignment_id: Uuid, + spent: Decimal, +} + impl Budget { + fn escape_like_pattern(s: &str) -> String { + s.replace('\\', r"\\").replace('%', r"\%").replace('_', r"\_") + } + fn search_pattern(search: Option<&str>) -> Option { - search.map(str::trim).filter(|s| !s.is_empty()).map(|s| format!("%{s}%")) + search.map(str::trim).filter(|s| !s.is_empty()).map(|s| format!("%{}%", Self::escape_like_pattern(s))) } fn budget_from_row(row: EffectiveBudgetRow) -> EffectiveBudget { @@ -43,7 +57,7 @@ impl Budget { SELECT id, name, description, amount, kind::text AS kind, interval::text AS interval, reset_strategy::text AS reset_strategy, on_exceed::text AS on_exceed, is_enabled, created_at, updated_at FROM budgets - WHERE ($1::text IS NULL OR name ILIKE $1) + WHERE ($1::text IS NULL OR name ILIKE $1 ESCAPE '\') ORDER BY name ASC LIMIT $2 OFFSET $3 "#, @@ -154,10 +168,19 @@ impl Budget { pub async fn assign_to_user(&self, pool: &PgPool, user_id: &Uuid) -> Result<(), sqlx::Error> { let mut tx = pool.begin().await?; - sqlx::query("DELETE FROM budget_assignments WHERE user_id = $1") - .bind(user_id) - .execute(&mut *tx) - .await?; + sqlx::query( + r#" + DELETE FROM budget_assignments ba + USING budgets b + WHERE ba.budget_id = b.id + AND ba.user_id = $1 + AND b.kind = $2::budget_kind + "#, + ) + .bind(user_id) + .bind(self.kind.as_str()) + .execute(&mut *tx) + .await?; sqlx::query("INSERT INTO budget_assignments (budget_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING") .bind(self.id) .bind(user_id) @@ -194,9 +217,10 @@ impl Budget { Ok(()) } - pub async fn delete_assignment(pool: &PgPool, assignment_id: &Uuid) -> Result<(), sqlx::Error> { - sqlx::query("DELETE FROM budget_assignments WHERE id = $1") + pub async fn delete_assignment(pool: &PgPool, budget_id: &Uuid, assignment_id: &Uuid) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM budget_assignments WHERE id = $1 AND budget_id = $2") .bind(assignment_id) + .bind(budget_id) .execute(pool) .await?; Ok(()) @@ -262,12 +286,21 @@ impl Budget { pub async fn status_for_user(pool: &PgPool, user_id: &Uuid) -> Result { let effective = Self::budgets_for_user(pool, user_id).await?; + let mut window_starts = Vec::with_capacity(effective.len()); + let mut resets_at = Vec::with_capacity(effective.len()); + for budget in &effective { + let (window_start, resets) = Self::window(budget); + window_starts.push(window_start); + resets_at.push(resets); + } + let spent_by_assignment = Self::spent_by_assignment(pool, user_id, &effective, &window_starts).await?; let mut budgets = Vec::with_capacity(effective.len()); let mut should_block = false; let mut should_warn = false; - for budget in effective { - let (window_start, resets_at) = Self::window(&budget); - let spent = Self::spent_in_window(pool, user_id, &budget, window_start).await?; + for (index, budget) in effective.into_iter().enumerate() { + let window_start = window_starts[index]; + let resets_at = resets_at[index]; + let spent = spent_by_assignment.get(&budget.assignment_id).copied().unwrap_or(Decimal::ZERO); let remaining = (budget.budget.amount - spent).max(Decimal::ZERO); let exhausted = spent >= budget.budget.amount; should_block |= exhausted && budget.budget.on_exceed == "block"; @@ -301,37 +334,47 @@ impl Budget { }) } - async fn spent_in_window(pool: &PgPool, user_id: &Uuid, budget: &EffectiveBudget, window_start: DateTime) -> Result { - if budget.budget.kind == "pooled" { - if let Some(team_id) = budget.team_id { - return sqlx::query_scalar( - r#" - SELECT COALESCE(SUM(ue.cost_total), 0) - FROM usage_events ue - WHERE ue.created_at >= $1 - AND EXISTS ( - SELECT 1 FROM team_members tm - WHERE tm.team_id = $2 AND tm.user_id = ue.user_id - ) - "#, - ) - .bind(window_start) - .bind(team_id) - .fetch_one(pool) - .await; - } + async fn spent_by_assignment( + pool: &PgPool, + user_id: &Uuid, + budgets: &[EffectiveBudget], + window_starts: &[DateTime], + ) -> Result, sqlx::Error> { + if budgets.is_empty() { + return Ok(HashMap::new()); } - sqlx::query_scalar( + let mut assignment_ids = Vec::with_capacity(budgets.len()); + let mut team_ids = Vec::with_capacity(budgets.len()); + let mut is_pooled_team = Vec::with_capacity(budgets.len()); + for budget in budgets { + assignment_ids.push(budget.assignment_id); + team_ids.push(budget.team_id.unwrap_or_else(Uuid::nil)); + is_pooled_team.push(budget.budget.kind == "pooled" && budget.team_id.is_some()); + } + let rows = sqlx::query_as::<_, BudgetSpentRow>( r#" - SELECT COALESCE(SUM(cost_total), 0) - FROM usage_events - WHERE user_id = $1 AND created_at >= $2 + SELECT budget_windows.assignment_id, COALESCE(SUM(ue.cost_total), 0)::numeric AS spent + FROM UNNEST($1::uuid[], $2::uuid[], $3::bool[], $4::timestamptz[]) AS budget_windows(assignment_id, team_id, is_pooled_team, window_start) + LEFT JOIN usage_events ue + ON ue.created_at >= budget_windows.window_start + AND ( + (budget_windows.is_pooled_team AND EXISTS ( + SELECT 1 FROM team_members tm + WHERE tm.team_id = budget_windows.team_id AND tm.user_id = ue.user_id + )) + OR (NOT budget_windows.is_pooled_team AND ue.user_id = $5) + ) + GROUP BY budget_windows.assignment_id "#, ) + .bind(&assignment_ids) + .bind(&team_ids) + .bind(&is_pooled_team) + .bind(window_starts) .bind(user_id) - .bind(window_start) - .fetch_one(pool) - .await + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|row| (row.assignment_id, row.spent)).collect()) } fn window(budget: &EffectiveBudget) -> (DateTime, Option>) { diff --git a/src/types/models/repository.rs b/src/types/models/repository.rs index e9d2f50..ccd12c8 100644 --- a/src/types/models/repository.rs +++ b/src/types/models/repository.rs @@ -5,6 +5,7 @@ use crate::types::PolicyResolver; use crate::types::axum::PaginatedResponse; use crate::types::models::ProviderTab; use crate::types::providers::{ProviderKind, ProviderModelResponse}; +use rust_decimal::Decimal; use serde_json::Value; use sqlx::types::Json; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -513,6 +514,14 @@ impl Model { } impl ModelPricing { + fn decimal_from_json(value: Option<&Value>) -> Option { + value.and_then(Value::as_f64).and_then(Decimal::from_f64_retain) + } + + fn millionths(tokens: i32, rate: Decimal) -> Decimal { + Decimal::from(tokens.max(0)) * rate / Decimal::from(1_000_000) + } + pub async fn effective(pool: &sqlx::PgPool, model_id: &Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, Self>( r#" @@ -583,6 +592,25 @@ impl ModelPricing { Ok(()) } + pub async fn usage_cost(pool: &sqlx::PgPool, model_id: &Uuid, input_tokens: i32, output_tokens: i32, reasoning_tokens: i32) -> Result, sqlx::Error> { + let Some(pricing) = Self::effective(pool, model_id).await? else { + return Ok(None); + }; + if pricing.override_pricing.is_none() && (pricing.reported_input.is_none() || pricing.reported_output.is_none()) { + return Ok(None); + } + let reasoning_rate = pricing.override_pricing.as_ref().and_then(|value| Self::decimal_from_json(value.get("reasoning"))); + let input_cost = Self::millionths(input_tokens, pricing.effective_input); + let output_cost = if let Some(reasoning_rate) = reasoning_rate { + let billed_reasoning_tokens = reasoning_tokens.max(0).min(output_tokens.max(0)); + let visible_output_tokens = output_tokens.max(0) - billed_reasoning_tokens; + Self::millionths(visible_output_tokens, pricing.effective_output) + Self::millionths(billed_reasoning_tokens, reasoning_rate) + } else { + Self::millionths(output_tokens, pricing.effective_output) + }; + Ok(Some(input_cost + output_cost)) + } + /// All pricing overrides keyed by the model's string identifier, for pushing /// into the omniference catalog as programmatic overrides. pub async fn all_overrides(pool: &sqlx::PgPool) -> Result, sqlx::Error> { From 9878b7110b3d22210c7175f83524ae7566406ecc Mon Sep 17 00:00:00 2001 From: Henrik Mertens Date: Mon, 6 Jul 2026 00:13:57 +0200 Subject: [PATCH 03/14] Update dependencies and upgrade omniference to 0.2 - Run `cargo update` to refresh all dependencies to latest compatible versions - Upgrade omniference from 0.1.8 to 0.2 (major version bump) --- Cargo.lock | 157 +++++++++++++++++++++----------------------------- Cargo.toml | 164 ++++++++++++++++++++++++++--------------------------- 2 files changed, 148 insertions(+), 173 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0022d7..221fd69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,9 +128,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" @@ -140,9 +140,9 @@ checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -161,9 +161,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "async-stream" @@ -554,9 +554,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", @@ -589,9 +589,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1079,7 +1079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1422,11 +1422,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1436,9 +1434,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1602,9 +1602,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] @@ -1953,9 +1953,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", @@ -1994,9 +1994,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -2194,7 +2194,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2246,9 +2246,7 @@ dependencies = [ [[package]] name = "omniference" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a14d600b1ba2cd3630f430a9869a5dd7cb45f0e0eec3f2afdb7ac3fc0a0907c3" +version = "0.2.0" dependencies = [ "anyhow", "async-stream", @@ -2456,7 +2454,7 @@ dependencies = [ "hmac", "md-5", "memchr", - "rand 0.10.1", + "rand 0.10.2", "sha2 0.11.0", "stringprep", ] @@ -2613,14 +2611,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -2634,9 +2633,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", @@ -2680,25 +2679,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", + "rand_chacha", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -2715,16 +2704,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -2736,18 +2715,18 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rand_core" -version = "0.10.1" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] [[package]] name = "rand_xoshiro" @@ -2986,9 +2965,9 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustix" @@ -3013,7 +2992,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3043,9 +3022,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -3482,7 +3461,7 @@ dependencies = [ "log", "md-5", "memchr", - "rand 0.10.1", + "rand 0.10.2", "rust_decimal", "serde", "serde_json", @@ -3646,7 +3625,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3709,9 +3688,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "num-conv", @@ -3729,9 +3708,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -4226,9 +4205,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -4306,9 +4285,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -4320,9 +4299,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.75" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4330,9 +4309,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4340,9 +4319,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -4353,9 +4332,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.125" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -4739,9 +4718,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.102" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -4835,7 +4814,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5240,7 +5219,3 @@ dependencies = [ "cc", "pkg-config", ] - -[[patch.unused]] -name = "omniference" -version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 365ae36..21166cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,82 +1,82 @@ -[package] -name = "OxideChat" -version = "0.2.0" -edition = "2024" -description = "A modern, performance and on customization focused AI chat application" -license = "MIT" -repository = "https://github.com/NxtCore/OxideChat" -readme = "README.md" -keywords = ["chat", "axum", "websocket", "postgres"] -categories = ["web-programming"] - -[dependencies] -axum = { version = "0.8" } -tokio = { version = "1.48", features = ["full"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -sqlx = { version = "0.9", features = [ - "postgres", - "chrono", - "tls-native-tls", - "runtime-tokio", - "macros", - "migrate", - "uuid", - "rust_decimal", -] } -dotenv = { version = "0.15" } -argon2 = { version = "0.5" } -uuid = { version = "1.0", features = ["serde", "v4"] } -chrono = { version = "0.4", features = ["serde"] } -tower-cookies = { version = "0.11" } -arc-swap = "1.7.1" -oauth2 = "5" -reqwest = { version = "0.12", default-features = false, features = [ - "rustls-tls", - "json", - "multipart", -] } -tower-http = { version = "0.5", features = ["cors"] } -omniference = "0.1.8" -aes-gcm = "0.10" -rand = "0.8" -rust_decimal = { version = "1.39.0", features = ["db-postgres", "serde"] } -futures-util = "0.3.31" -async-stream = "0.3.6" -tokio-util = "0.7.17" -extism = "1" -sha2 = "0.10" -base64 = "0.22" -thiserror = "2" -async-trait = "0.1" -infer = "0.16" - -[lints.clippy] -correctness = { level = "deny", priority = -1 } -suspicious = { level = "deny", priority = -1 } -perf = { level = "warn", priority = -1 } -style = { level = "warn", priority = -1 } -complexity = { level = "warn", priority = -1 } -must_use_candidate = "warn" -missing_errors_doc = "warn" -missing_panics_doc = "warn" -doc_markdown = "warn" -explicit_iter_loop = "warn" -explicit_into_iter_loop = "warn" -inconsistent_struct_constructor = "warn" -manual_let_else = "warn" -redundant_closure_for_method_calls = "warn" -semicolon_if_nothing_returned = "warn" -unnested_or_patterns = "warn" -unused_self = "warn" -cargo = { level = "warn", priority = -1 } -future_not_send = "warn" -fallible_impl_from = "warn" -dbg_macro = "warn" -todo = "warn" -unimplemented = "warn" -multiple_crate_versions = "allow" - -[lints.rust] -unsafe_code = "warn" -missing_docs = "allow" +[package] +name = "OxideChat" +version = "0.2.0" +edition = "2024" +description = "A modern, performance and on customization focused AI chat application" +license = "MIT" +repository = "https://github.com/NxtCore/OxideChat" +readme = "README.md" +keywords = ["chat", "axum", "websocket", "postgres"] +categories = ["web-programming"] + +[dependencies] +axum = { version = "0.8" } +tokio = { version = "1.48", features = ["full"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sqlx = { version = "0.9", features = [ + "postgres", + "chrono", + "tls-native-tls", + "runtime-tokio", + "macros", + "migrate", + "uuid", + "rust_decimal", +] } +dotenv = { version = "0.15" } +argon2 = { version = "0.5" } +uuid = { version = "1.0", features = ["serde", "v4"] } +chrono = { version = "0.4", features = ["serde"] } +tower-cookies = { version = "0.11" } +arc-swap = "1.7.1" +oauth2 = "5" +reqwest = { version = "0.12", default-features = false, features = [ + "rustls-tls", + "json", + "multipart", +] } +tower-http = { version = "0.5", features = ["cors"] } +omniference = "0.2" +aes-gcm = "0.10" +rand = "0.8" +rust_decimal = { version = "1.39.0", features = ["db-postgres", "serde"] } +futures-util = "0.3.31" +async-stream = "0.3.6" +tokio-util = "0.7.17" +extism = "1" +sha2 = "0.10" +base64 = "0.22" +thiserror = "2" +async-trait = "0.1" +infer = "0.16" + +[lints.clippy] +correctness = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +perf = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +must_use_candidate = "warn" +missing_errors_doc = "warn" +missing_panics_doc = "warn" +doc_markdown = "warn" +explicit_iter_loop = "warn" +explicit_into_iter_loop = "warn" +inconsistent_struct_constructor = "warn" +manual_let_else = "warn" +redundant_closure_for_method_calls = "warn" +semicolon_if_nothing_returned = "warn" +unnested_or_patterns = "warn" +unused_self = "warn" +cargo = { level = "warn", priority = -1 } +future_not_send = "warn" +fallible_impl_from = "warn" +dbg_macro = "warn" +todo = "warn" +unimplemented = "warn" +multiple_crate_versions = "allow" + +[lints.rust] +unsafe_code = "warn" +missing_docs = "allow" From 4ac9fd03a66edb7cff1cc8cdee53b7a23812e145 Mon Sep 17 00:00:00 2001 From: Henrik Mertens Date: Mon, 6 Jul 2026 00:48:40 +0200 Subject: [PATCH 04/14] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ca3796c..2044de0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN bun run build # ----------------------------------------------------------------------------- -FROM rust:1.96-slim AS chef +FROM rust:1.96-slim-bookworm AS chef RUN apt-get update && apt-get install -y --no-install-recommends \ pkg-config libssl-dev \ From 5576c7de64fb051d83fa5c71ec016c8fa86d848f Mon Sep 17 00:00:00 2001 From: Henrik-3 Date: Mon, 6 Jul 2026 20:29:17 +0200 Subject: [PATCH 05/14] Integrate reusable date filter component in analytics dashboard - Replace custom date controls in `AnalyticsDashboard.vue` with `AnalyticsDateFilter` - Add logic for date preset selections and custom range handling in `AnalyticsDateFilter.vue` - Enhance stacked bar chart tooltips and hover interactions in `StackedAnalyticsChart.vue` - Add i18n translations for new analytics presets and sections in English and German - Update migration scripts to include additional analytics translations --- frontend/components/chat/ModelSelector.vue | 28 +- .../settings/AnalyticsDashboard.vue | 274 +++++++++--------- .../settings/AnalyticsDateFilter.vue | 152 ++++++++++ .../settings/StackedAnalyticsChart.vue | 79 +++-- frontend/pages/settings/teams.vue | 43 +-- .../20260706200000_analytics_ui_i18n.sql | 26 ++ .../20260706210000_analytics_trends_i18n.sql | 14 + 7 files changed, 416 insertions(+), 200 deletions(-) create mode 100644 frontend/components/settings/AnalyticsDateFilter.vue create mode 100644 migrations/20260706200000_analytics_ui_i18n.sql create mode 100644 migrations/20260706210000_analytics_trends_i18n.sql diff --git a/frontend/components/chat/ModelSelector.vue b/frontend/components/chat/ModelSelector.vue index 867b49f..ae2d975 100644 --- a/frontend/components/chat/ModelSelector.vue +++ b/frontend/components/chat/ModelSelector.vue @@ -200,7 +200,7 @@ diff --git a/frontend/components/settings/AnalyticsDashboard.vue b/frontend/components/settings/AnalyticsDashboard.vue index 7b40e61..f6db1e7 100644 --- a/frontend/components/settings/AnalyticsDashboard.vue +++ b/frontend/components/settings/AnalyticsDashboard.vue @@ -1,28 +1,12 @@ diff --git a/frontend/components/settings/AnalyticsDateFilter.vue b/frontend/components/settings/AnalyticsDateFilter.vue new file mode 100644 index 0000000..6588732 --- /dev/null +++ b/frontend/components/settings/AnalyticsDateFilter.vue @@ -0,0 +1,152 @@ + + + diff --git a/frontend/components/settings/StackedAnalyticsChart.vue b/frontend/components/settings/StackedAnalyticsChart.vue index 90552a6..ff829ed 100644 --- a/frontend/components/settings/StackedAnalyticsChart.vue +++ b/frontend/components/settings/StackedAnalyticsChart.vue @@ -4,7 +4,7 @@ :viewBox="`0 0 ${chartWidth} ${chartHeight}`" class="h-[280px] w-full overflow-visible" role="img" - @mouseleave="hoveredSegment = null" + @mouseleave="hoveredBar = null" > +
-

{{ hoveredSegment.day }}

-
-
- - {{ hoveredSegment.name }} +

{{ hoveredBar.day }}

+
+
+
+ + {{ segment.name }} +
+ {{ formatValue(segment.value) }}
- {{ formatValue(hoveredSegment.value) }} +
+
+ Total + {{ formatValue(hoveredBar.total) }}
@@ -106,7 +123,10 @@ type ChartSegment = { height: number; }; -type HoveredSegment = ChartSegment & { +type HoveredBar = { + day: string; + segments: ChartSegment[]; + total: number; x: number; y: number; }; @@ -119,7 +139,7 @@ const props = defineProps<{ const chartRef = ref(null); const disabledKeys = ref([]); -const hoveredSegment = ref(null); +const hoveredBar = ref(null); const chartWidth = 1000; const chartHeight = 280; @@ -184,6 +204,11 @@ const chartBars = computed(() => { }); }); +const hoveredBarSortedSegments = computed(() => { + if (!hoveredBar.value) return []; + return [...hoveredBar.value.segments].sort((a, b) => b.value - a.value); +}); + const yTicks = computed(() => { const ticks = 3; return Array.from({length: ticks + 1}, (_, index) => { @@ -213,16 +238,20 @@ function categoryColor(category?: ChartCategory): string { return color ?? '#6b7280'; } -function setHoveredSegment(event: MouseEvent | FocusEvent, segment: ChartSegment) { - if (!(event instanceof MouseEvent) || !chartRef.value) { - hoveredSegment.value = {...segment, x: 16, y: 16}; - return; - } +function setHoveredBar(event: MouseEvent, bar: {day: string; segments: ChartSegment[]; x: number; width: number}) { + if (!chartRef.value) return; const bounds = chartRef.value.getBoundingClientRect(); - const tooltipWidth = 220; + const tooltipWidth = 230; const x = Math.min(event.clientX - bounds.left + 14, Math.max(14, bounds.width - tooltipWidth)); - const y = Math.max(8, event.clientY - bounds.top - 82); - hoveredSegment.value = {...segment, x, y}; + const y = Math.max(8, event.clientY - bounds.top - 100); + const total = bar.segments.reduce((sum, s) => sum + s.value, 0); + hoveredBar.value = { + day: bar.day, + segments: bar.segments, + total, + x, + y, + }; } function toggleKey(key: string) { @@ -236,7 +265,7 @@ function toggleKey(key: string) { disabled.delete(key); } disabledKeys.value = Array.from(disabled); - hoveredSegment.value = null; + hoveredBar.value = null; } function formatTick(value: number): string { diff --git a/frontend/pages/settings/teams.vue b/frontend/pages/settings/teams.vue index d35eb2e..b18d539 100644 --- a/frontend/pages/settings/teams.vue +++ b/frontend/pages/settings/teams.vue @@ -86,7 +86,6 @@ {{ store.getTranslation('settings.teams.members') }} {{ store.getTranslation('settings.teams.models') }} - {{ store.getTranslation('settings.teams.budget') }} @@ -223,35 +222,6 @@
- - - -
-
- -
-
-

{{ store.getTranslation('settings.teams.budget_link_title') }}

-

{{ store.getTranslation('settings.teams.budget_link_hint') }}

-
-
- -
-
-

{{ store.getTranslation('settings.teams.budget_linked') }}

-

{{ budgetId }}

-
- - {{ store.getTranslation('settings.teams.budget_unlink') }} - -
- -
-

{{ store.getTranslation('settings.teams.budget_not_linked') }}

-

{{ store.getTranslation('settings.teams.budget_none_available') }}

-
- -
@@ -314,7 +284,7 @@ diff --git a/frontend/pages/settings/analytics.vue b/frontend/pages/settings/analytics.vue index 52d5523..2aa8533 100644 --- a/frontend/pages/settings/analytics.vue +++ b/frontend/pages/settings/analytics.vue @@ -1,8 +1,10 @@ diff --git a/frontend/pages/settings/users.vue b/frontend/pages/settings/users.vue index 4b26ffd..c8925af 100644 --- a/frontend/pages/settings/users.vue +++ b/frontend/pages/settings/users.vue @@ -77,6 +77,14 @@
+ + + import {ref, reactive, computed, watch, onMounted} from 'vue'; -import {Users, Plus, Pencil, Trash2, Loader2} from 'lucide-vue-next'; +import {Users, Plus, Pencil, Trash2, Loader2, BarChart2} from 'lucide-vue-next'; import {useMainStore} from '@/stores'; import type {PaginatedResponse, TeamList, TeamSummary} from '~/types/chat'; const {$customFetch} = useNuxtApp(); const store = useMainStore(); +const router = useRouter(); interface UserResponse { id: string; diff --git a/frontend/stores/budgetStore.ts b/frontend/stores/budgetStore.ts index 2fc1810..8d67e9c 100644 --- a/frontend/stores/budgetStore.ts +++ b/frontend/stores/budgetStore.ts @@ -18,6 +18,11 @@ export const useBudgetStore = defineStore('budget', { byDay: [] as AnalyticsRow[], byDayModel: [] as AnalyticsDayModelRow[], }, + userAnalytics: { + byModel: [] as AnalyticsRow[], + byDay: [] as AnalyticsRow[], + byDayModel: [] as AnalyticsDayModelRow[], + }, assignments: [] as BudgetAssignmentInfo[], loading: false, }), @@ -95,6 +100,17 @@ export const useBudgetStore = defineStore('budget', { this.analytics.byDay = byDay ?? []; this.analytics.byDayModel = byDayModel ?? []; }, + async fetchUserAnalytics(userId: string, params: {from?: string; to?: string} = {}) { + const {$customFetch} = useNuxtApp(); + const [byModel, byDay, byDayModel] = await Promise.all([ + $customFetch('/api/v1/admin/analytics', {params: {...params, group_by: 'model', user_id: userId}}), + $customFetch('/api/v1/admin/analytics', {params: {...params, group_by: 'day', user_id: userId}}), + $customFetch('/api/v1/admin/analytics', {params: {...params, group_by: 'day_model', user_id: userId}}), + ]); + this.userAnalytics.byModel = byModel ?? []; + this.userAnalytics.byDay = byDay ?? []; + this.userAnalytics.byDayModel = byDayModel ?? []; + }, async fetchMyAnalytics(params: {from?: string; to?: string} = {}) { const {$customFetch} = useNuxtApp(); const [byModel, byDay, byDayModel] = await Promise.all([ diff --git a/src/routes/admin/analytics.rs b/src/routes/admin/analytics.rs index 31c85ec..10b4030 100644 --- a/src/routes/admin/analytics.rs +++ b/src/routes/admin/analytics.rs @@ -17,6 +17,24 @@ pub async fn get_analytics(State(state): State>, cookies: Cookies, return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); } let group_by = params.group_by.as_deref().unwrap_or("model"); + if let Some(ref user_id) = params.user_id { + if group_by == "day_model" { + return match UsageEvent::day_model_analytics(&state.db, params.from, params.to, Some(user_id)).await { + Ok(rows) => ResponseBuilder::new(ResponseBody::Json(rows)).build(), + Err(e) => { + eprintln!("[ANALYTICS] Failed to load day_model analytics: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + }; + } + return match UsageEvent::analytics_for_user(&state.db, user_id, params.from, params.to, group_by).await { + Ok(rows) => ResponseBuilder::new(ResponseBody::Json(rows)).build(), + Err(e) => { + eprintln!("[ANALYTICS] Failed to load user analytics: {e}"); + ErrorBuilder::new(ErrorCode::DatabaseError).build() + } + }; + } if group_by == "day_model" { return match UsageEvent::day_model_analytics(&state.db, params.from, params.to, None).await { Ok(rows) => ResponseBuilder::new(ResponseBody::Json(rows)).build(), diff --git a/src/types/usage/mod.rs b/src/types/usage/mod.rs index d83d1e5..912c354 100644 --- a/src/types/usage/mod.rs +++ b/src/types/usage/mod.rs @@ -37,6 +37,7 @@ pub struct AnalyticsQuery { pub from: Option>, pub to: Option>, pub group_by: Option, + pub user_id: Option, } #[derive(Debug, Serialize, sqlx::FromRow)] From 5b9ae74b1abc19ac7e01c413a741772d77258e12 Mon Sep 17 00:00:00 2001 From: Henrik-3 Date: Mon, 6 Jul 2026 22:11:25 +0200 Subject: [PATCH 10/14] Add budget reset functionality, enhanced overview endpoints, and redesigned frontend for budget management - Backend changes: - Add `BudgetResetRequest` and `BudgetResetEventResponse` structures - Implement budget reset logic and latest resets retrieval for users/teams - Introduce `user_overview` and `team_overview` endpoints for detailed summaries - Add SQL queries for reset events, spending aggregation, and budget usage tracking - Frontend changes: - Redesign budget management interface with tabs for users, teams, budgets, and history - Implement budget reset actions with expanded tables for user/team details - Add search filters, enhanced UI components, and i18n support for new features --- frontend/pages/settings/budgets.vue | 654 ++++++++++++------ frontend/stores/budgetStore.ts | 37 +- frontend/types/budgets.ts | 63 ++ .../20260706010000_budget_manual_resets.sql | 96 +++ src/routes/admin/budgets.rs | 70 +- src/routes/mod.rs | 8 +- src/types/budgets/repository.rs | 296 +++++++- src/types/budgets/requests.rs | 10 + src/types/budgets/responses.rs | 59 ++ 9 files changed, 1092 insertions(+), 201 deletions(-) create mode 100644 migrations/20260706010000_budget_manual_resets.sql diff --git a/frontend/pages/settings/budgets.vue b/frontend/pages/settings/budgets.vue index cefed83..a114441 100644 --- a/frontend/pages/settings/budgets.vue +++ b/frontend/pages/settings/budgets.vue @@ -1,238 +1,405 @@ diff --git a/frontend/pages/settings/budgets.vue b/frontend/pages/settings/budgets.vue index 1cc43ce..ee13db5 100644 --- a/frontend/pages/settings/budgets.vue +++ b/frontend/pages/settings/budgets.vue @@ -540,8 +540,9 @@ async function confirmReset() { }); resetOpen.value = false; if (selected.value) await budgetStore.fetchAssignments(selected.value.id); + store.toast(tx('settings.budgets.reset_success', 'Budget reset'), {type: 'success'}); } catch { - store.toast(store.getTranslation('common.error'), {type: 'error'}); + store.toast(tx('settings.budgets.reset_error', 'Failed to reset budget'), {type: 'error'}); } } @@ -557,8 +558,9 @@ async function createBudget() { const budget = await budgetStore.createBudget({...form, name: form.name || store.getTranslation('settings.tabs.budgets')}); await selectBudget(budget); await loadOperationalData(); + store.toast(tx('settings.budgets.create_success', 'Budget created'), {type: 'success'}); } catch { - store.toast(store.getTranslation('common.error'), {type: 'error'}); + store.toast(tx('settings.budgets.create_error', 'Failed to create budget'), {type: 'error'}); } } @@ -568,8 +570,9 @@ async function saveBudget() { const budget = await budgetStore.updateBudget(selected.value.id, {...form}); await selectBudget(budget); await loadOperationalData(); + store.toast(tx('settings.budgets.save_success', 'Budget saved'), {type: 'success'}); } catch { - store.toast(store.getTranslation('common.error'), {type: 'error'}); + store.toast(tx('settings.budgets.save_error', 'Failed to save budget'), {type: 'error'}); } } @@ -580,8 +583,9 @@ async function deleteBudget() { selected.value = budgetStore.budgets[0] ?? null; if (selected.value) await selectBudget(selected.value); await loadOperationalData(); + store.toast(tx('settings.budgets.delete_success', 'Budget deleted'), {type: 'success'}); } catch { - store.toast(store.getTranslation('common.error'), {type: 'error'}); + store.toast(tx('settings.budgets.delete_error', 'Failed to delete budget'), {type: 'error'}); } } @@ -591,8 +595,9 @@ async function assignTeam() { await budgetStore.assignBudget(selected.value.id, {team_id: selectedTeamId.value}); selectedTeamId.value = null; await loadOperationalData(); + store.toast(tx('settings.budgets.assign_success', 'Budget assigned'), {type: 'success'}); } catch { - store.toast(store.getTranslation('common.error'), {type: 'error'}); + store.toast(tx('settings.budgets.assign_error', 'Failed to assign budget'), {type: 'error'}); } } @@ -602,8 +607,9 @@ async function assignUser() { await budgetStore.assignBudget(selected.value.id, {user_id: selectedUserId.value}); selectedUserId.value = null; await loadOperationalData(); + store.toast(tx('settings.budgets.assign_success', 'Budget assigned'), {type: 'success'}); } catch { - store.toast(store.getTranslation('common.error'), {type: 'error'}); + store.toast(tx('settings.budgets.assign_error', 'Failed to assign budget'), {type: 'error'}); } } @@ -612,8 +618,9 @@ async function removeAssignment(a: BudgetAssignmentInfo) { try { await budgetStore.removeAssignment(selected.value.id, a.id); await loadOperationalData(); + store.toast(tx('settings.budgets.remove_success', 'Assignment removed'), {type: 'success'}); } catch { - store.toast(store.getTranslation('common.error'), {type: 'error'}); + store.toast(tx('settings.budgets.remove_error', 'Failed to remove assignment'), {type: 'error'}); } } diff --git a/frontend/pages/settings/models/[id].vue b/frontend/pages/settings/models/[id].vue index 82a18ac..d104128 100644 --- a/frontend/pages/settings/models/[id].vue +++ b/frontend/pages/settings/models/[id].vue @@ -858,23 +858,23 @@
{{ store.getTranslation('settings.models.editor.override_input') }} - +
{{ store.getTranslation('settings.models.editor.override_output') }} - +
{{ store.getTranslation('settings.models.editor.override_reasoning') }} - +
{{ store.getTranslation('settings.models.editor.override_cache_read') }} - +
{{ store.getTranslation('settings.models.editor.override_cache_write') }} - +

{{ store.getTranslation('settings.models.editor.pricing_unit') }}

diff --git a/src/routes/admin/budgets.rs b/src/routes/admin/budgets.rs index 0184e7c..99eba9b 100644 --- a/src/routes/admin/budgets.rs +++ b/src/routes/admin/budgets.rs @@ -38,6 +38,12 @@ pub async fn create_budget(State(state): State>, cookies: Cookies, if req.name.trim().is_empty() { return ErrorBuilder::new(ErrorCode::ValidationFailed).build(); } + if req.amount <= rust_decimal::Decimal::ZERO { + return ErrorBuilder::new(ErrorCode::ValidationFailed).build(); + } + if !req.has_valid_enums() { + return ErrorBuilder::new(ErrorCode::ValidationFailed).build(); + } match Budget::create(&state.db, &req).await { Ok(budget) => ResponseBuilder::new(ResponseBody::Json(crate::types::BudgetResponse::from(budget))) .status(StatusCode::CREATED) @@ -56,6 +62,12 @@ pub async fn update_budget(State(state): State>, cookies: Cookies, if !user.has_permission(&state.db, ADMIN_BUDGETS_EDIT).await { return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); } + if req.amount.is_some_and(|amount| amount <= rust_decimal::Decimal::ZERO) { + return ErrorBuilder::new(ErrorCode::ValidationFailed).build(); + } + if !req.has_valid_enums() { + return ErrorBuilder::new(ErrorCode::ValidationFailed).build(); + } let budget = match Budget::find_by_id(&state.db, &id).await { Ok(Some(budget)) => budget, Ok(None) => return ErrorBuilder::new(ErrorCode::NotFound).build(), @@ -104,6 +116,9 @@ pub async fn assign_budget(State(state): State>, cookies: Cookies, if !user.has_permission(&state.db, ADMIN_BUDGETS_EDIT).await { return ErrorBuilder::new(ErrorCode::InsufficientPermissions).build(); } + if req.team_id.is_none() && req.user_id.is_none() { + return ErrorBuilder::new(ErrorCode::ValidationFailed).build(); + } let budget = match Budget::find_by_id(&state.db, &id).await { Ok(Some(budget)) => budget, Ok(None) => return ErrorBuilder::new(ErrorCode::NotFound).build(), diff --git a/src/types/budgets/requests.rs b/src/types/budgets/requests.rs index d68edd9..e633f1d 100644 --- a/src/types/budgets/requests.rs +++ b/src/types/budgets/requests.rs @@ -2,6 +2,11 @@ use rust_decimal::Decimal; use serde::Deserialize; use uuid::Uuid; +const VALID_KINDS: [&str; 2] = ["pooled", "per_user"]; +const VALID_INTERVALS: [&str; 4] = ["daily", "weekly", "monthly", "total"]; +const VALID_RESET_STRATEGIES: [&str; 3] = ["rolling", "anchored", "calendar"]; +const VALID_ON_EXCEED: [&str; 3] = ["block", "warn", "allow"]; + #[derive(Debug, Deserialize)] pub struct ListBudgetsQuery { pub page: Option, @@ -21,6 +26,16 @@ pub struct CreateBudgetRequest { pub is_enabled: Option, } +impl CreateBudgetRequest { + #[must_use] + pub fn has_valid_enums(&self) -> bool { + VALID_KINDS.contains(&self.kind.as_str()) + && VALID_INTERVALS.contains(&self.interval.as_str()) + && VALID_RESET_STRATEGIES.contains(&self.reset_strategy.as_str()) + && VALID_ON_EXCEED.contains(&self.on_exceed.as_str()) + } +} + #[derive(Debug, Deserialize)] pub struct UpdateBudgetRequest { pub name: Option, @@ -33,6 +48,16 @@ pub struct UpdateBudgetRequest { pub is_enabled: Option, } +impl UpdateBudgetRequest { + #[must_use] + pub fn has_valid_enums(&self) -> bool { + self.kind.as_deref().is_none_or(|kind| VALID_KINDS.contains(&kind)) + && self.interval.as_deref().is_none_or(|interval| VALID_INTERVALS.contains(&interval)) + && self.reset_strategy.as_deref().is_none_or(|reset_strategy| VALID_RESET_STRATEGIES.contains(&reset_strategy)) + && self.on_exceed.as_deref().is_none_or(|on_exceed| VALID_ON_EXCEED.contains(&on_exceed)) + } +} + #[derive(Debug, Deserialize)] pub struct BudgetAssignmentRequest { pub team_id: Option, From 0e958fbc910acff224c9017fa70b355b6d225a84 Mon Sep 17 00:00:00 2001 From: Henrik Mertens Date: Tue, 7 Jul 2026 00:13:34 +0200 Subject: [PATCH 14/14] Add budget success and error toast translations and disable CodeRabbit auto-review - Add i18n keys for budget CRUD, assignment, and reset actions in English and German - Update `.coderabbit.yaml` to disable `auto_review` --- .coderabbit.yaml | 3 +++ .../20260706010000_budget_manual_resets.sql | 26 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..d42cf68 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,3 @@ +reviews: + auto_review: + enabled: false \ No newline at end of file diff --git a/migrations/20260706010000_budget_manual_resets.sql b/migrations/20260706010000_budget_manual_resets.sql index 1eb3be0..e6ced2d 100644 --- a/migrations/20260706010000_budget_manual_resets.sql +++ b/migrations/20260706010000_budget_manual_resets.sql @@ -55,6 +55,18 @@ INSERT INTO i18n_translations (language, key_path, value) VALUES ('en', 'settings.budgets.reason_placeholder', 'Optional note for the audit log'), ('en', 'settings.budgets.reset_keeps_history', 'Usage history is kept. This starts budget accounting from now for the selected scope.'), ('en', 'settings.budgets.assignment', 'Assignment'), + ('en', 'settings.budgets.create_success', 'Budget created'), + ('en', 'settings.budgets.create_error', 'Failed to create budget'), + ('en', 'settings.budgets.save_success', 'Budget saved'), + ('en', 'settings.budgets.save_error', 'Failed to save budget'), + ('en', 'settings.budgets.delete_success', 'Budget deleted'), + ('en', 'settings.budgets.delete_error', 'Failed to delete budget'), + ('en', 'settings.budgets.assign_success', 'Budget assigned'), + ('en', 'settings.budgets.assign_error', 'Failed to assign budget'), + ('en', 'settings.budgets.remove_success', 'Assignment removed'), + ('en', 'settings.budgets.remove_error', 'Failed to remove assignment'), + ('en', 'settings.budgets.reset_success', 'Budget reset'), + ('en', 'settings.budgets.reset_error', 'Failed to reset budget'), ('de', 'common.refresh', 'Aktualisieren'), ('de', 'settings.budgets.tab_users', 'Benutzer'), ('de', 'settings.budgets.tab_teams', 'Teams'), @@ -89,5 +101,17 @@ INSERT INTO i18n_translations (language, key_path, value) VALUES ('de', 'settings.budgets.all_kinds', 'Alle Arten'), ('de', 'settings.budgets.reason_placeholder', 'Optionale Notiz fuer das Audit-Log'), ('de', 'settings.budgets.reset_keeps_history', 'Die Nutzungshistorie bleibt erhalten. Die Budgetberechnung startet fuer diesen Bereich ab jetzt neu.'), - ('de', 'settings.budgets.assignment', 'Zuweisung') + ('de', 'settings.budgets.assignment', 'Zuweisung'), + ('de', 'settings.budgets.create_success', 'Budget erstellt'), + ('de', 'settings.budgets.create_error', 'Budget konnte nicht erstellt werden'), + ('de', 'settings.budgets.save_success', 'Budget gespeichert'), + ('de', 'settings.budgets.save_error', 'Budget konnte nicht gespeichert werden'), + ('de', 'settings.budgets.delete_success', 'Budget geloescht'), + ('de', 'settings.budgets.delete_error', 'Budget konnte nicht geloescht werden'), + ('de', 'settings.budgets.assign_success', 'Budget zugewiesen'), + ('de', 'settings.budgets.assign_error', 'Budget konnte nicht zugewiesen werden'), + ('de', 'settings.budgets.remove_success', 'Zuweisung entfernt'), + ('de', 'settings.budgets.remove_error', 'Zuweisung konnte nicht entfernt werden'), + ('de', 'settings.budgets.reset_success', 'Budget zurueckgesetzt'), + ('de', 'settings.budgets.reset_error', 'Budget konnte nicht zurueckgesetzt werden') ON CONFLICT (language, key_path) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW();