diff --git a/README.md b/README.md index 65067e1..036081c 100644 --- a/README.md +++ b/README.md @@ -666,8 +666,11 @@ See [UPGRADE.md](UPGRADE.md) for schema changes, plaintext migration, and key ha ## Data-driven budgets -Admins configure **Assistant > Budgets** for UTC DAY or MONTH allowances in USD or -TOKENS, scoped to INSTANCE, USER, ROLE (a permission), PROVIDER or MODEL. Provider +Admins configure **Assistant > Budgets** for UTC DAY, WEEK (from Monday 00:00 UTC) or +MONTH allowances in USD or TOKENS, scoped to INSTANCE, USER, ROLE (a permission, each +holder counted separately), PROVIDER or MODEL. The rows are read at every question +and before every model call, so a changed limit applies at once. A cost limit above +`budgetCostLimitCeilingUsd` (default 1,000,000) is refused when saved. Provider and model scope values are registry record ids. Warnings default to 80%; at 100% the default action is DOWNGRADE, and the hard ceiling defaults to 120% (always STOP). Multiple budgets apply together and STOP wins. diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java index a6545d0..9cf0175 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/AssistantQBitConfig.java @@ -19,6 +19,7 @@ package com.kingsrook.qbits.assistant; import javax.net.ssl.SSLContext; import java.io.IOException; +import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -120,6 +121,7 @@ public class AssistantQBitConfig implements QBitConfig private Integer toolResultRetentionDays = 30; private Integer approvalRetentionDays = 365; private Integer policyCacheSeconds = 30; + private BigDecimal budgetCostLimitCeilingUsd = new BigDecimal("1000000"); private String ownerSecurityKeyType = DEFAULT_OWNER_KEY_TYPE; private String shortcut = DEFAULT_SHORTCUT; private Set allowedScheduledCredentialEnvNames = new LinkedHashSet<>(); @@ -286,6 +288,10 @@ public class AssistantQBitConfig implements QBitConfig errors.add("customRedactionPatterns: not a valid regular expression: " + pattern); } } + if(budgetCostLimitCeilingUsd != null && budgetCostLimitCeilingUsd.signum() <= 0) + { + errors.add("budgetCostLimitCeilingUsd must be positive, or null for no ceiling"); + } if(policyCacheSeconds == null || policyCacheSeconds < 0) { errors.add("policyCacheSeconds must not be negative"); @@ -1133,6 +1139,40 @@ public class AssistantQBitConfig implements QBitConfig + /******************************************************************************* + ** Getter for budgetCostLimitCeilingUsd + *******************************************************************************/ + public BigDecimal getBudgetCostLimitCeilingUsd() + { + return (this.budgetCostLimitCeilingUsd); + } + + + + /******************************************************************************* + ** Setter for budgetCostLimitCeilingUsd + *******************************************************************************/ + public void setBudgetCostLimitCeilingUsd(BigDecimal budgetCostLimitCeilingUsd) + { + this.budgetCostLimitCeilingUsd = budgetCostLimitCeilingUsd; + } + + + + /******************************************************************************* + ** The most US dollars an administrator may save as the limit of one cost + ** budget (Assistant > Budgets), for one period. A larger number is refused + ** when the budget is saved, so a slip of the keyboard cannot remove the + ** limit in effect. Default 1,000,000; null removes the ceiling. + *******************************************************************************/ + public AssistantQBitConfig withBudgetCostLimitCeilingUsd(BigDecimal budgetCostLimitCeilingUsd) + { + this.budgetCostLimitCeilingUsd = budgetCostLimitCeilingUsd; + return (this); + } + + + /******************************************************************************* ** Getter for policyCacheSeconds *******************************************************************************/ diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/BudgetService.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/BudgetService.java index 137246e..504dabe 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/BudgetService.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/engine/BudgetService.java @@ -32,20 +32,39 @@ import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.Clock; +import java.time.DayOfWeek; import java.time.Instant; import java.time.LocalDate; import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAdjusters; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Objects; /******************************************************************************* ** Data-driven budgets layered on the existing policy and provider caps. ** Evaluation occurs at dispatch boundaries, never during streaming tokens. + ** + ** The budget rows are read from their table at every evaluation and nothing + ** of them is cached, so a limit an administrator changes applies to the next + ** question and to the next model call of a running answer, with no restart. + ** + ** Periods are UTC: a DAY starts at 00:00 UTC, a WEEK on Monday at 00:00 UTC + ** (the ISO week) and a MONTH on the first of the month at 00:00 UTC. *******************************************************************************/ public class BudgetService { + public static final String PERIOD_DAY = "DAY"; + public static final String PERIOD_WEEK = "WEEK"; + public static final String PERIOD_MONTH = "MONTH"; + + public static final List PERIODS = List.of(PERIOD_DAY, PERIOD_WEEK, PERIOD_MONTH); + + private static final DateTimeFormatter RESET_DAY = DateTimeFormatter.ofPattern("EEEE d MMMM uuuu", Locale.ENGLISH); + private final AssistantStore store; private final ProviderService providers; private final Clock clock; @@ -138,12 +157,12 @@ public class BudgetService } if("STOPPED".equals(state)) { - message = "Assistant budget '" + budget.getName() + "' has been reached. Ask an administrator for an override."; + message = stoppedMessage(snapshot); } if("WARNED".equals(state)) { message = "You have used " + snapshot.percent().setScale(0, RoundingMode.DOWN) + "% of the " - + ("DAY".equals(budget.getPeriod()) ? "daily" : "monthly") + " assistant budget '" + budget.getName() + "'."; + + periodWord(budget.getPeriod()) + " assistant budget '" + budget.getName() + "'."; } if("STOPPED".equals(state)) { @@ -222,6 +241,83 @@ public class BudgetService + /******************************************************************************* + ** The first instant of the period that holds `now`: 00:00 UTC of the day, of + ** the Monday of the week (ISO week) or of the first of the month. + *******************************************************************************/ + public static Instant periodStart(String period, Instant now) + { + LocalDate day = LocalDate.ofInstant(now, ZoneOffset.UTC); + LocalDate start = switch(String.valueOf(period)) + { + case PERIOD_MONTH -> day.withDayOfMonth(1); + case PERIOD_WEEK -> day.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + default -> day; + }; + return (start.atStartOfDay().toInstant(ZoneOffset.UTC)); + } + + + + /******************************************************************************* + ** The first instant after the period that starts at `start`, which is when + ** its count starts again at zero. + *******************************************************************************/ + public static Instant periodEnd(String period, Instant start) + { + LocalDate day = LocalDate.ofInstant(start, ZoneOffset.UTC); + LocalDate end = switch(String.valueOf(period)) + { + case PERIOD_MONTH -> day.plusMonths(1); + case PERIOD_WEEK -> day.plusWeeks(1); + default -> day.plusDays(1); + }; + return (end.atStartOfDay().toInstant(ZoneOffset.UTC)); + } + + + + /******************************************************************************* + ** "daily", "weekly" or "monthly". + *******************************************************************************/ + public static String periodWord(String period) + { + return (switch(String.valueOf(period)) + { + case PERIOD_MONTH -> "monthly"; + case PERIOD_WEEK -> "weekly"; + default -> "daily"; + }); + } + + + + /******************************************************************************* + ** A limit as a person reads it: US dollars with cents, or whole tokens. + *******************************************************************************/ + public static String amount(AssistantBudget budget, BigDecimal value) + { + return ("COST_USD".equals(budget.getMetric()) ? "$" + String.format(Locale.ENGLISH, "%,.2f", value.setScale(2, RoundingMode.HALF_UP)) + : String.format(Locale.ENGLISH, "%,d", value.toBigInteger()) + " tokens"); + } + + + + /******************************************************************************* + ** What a person is told when a budget refuses: which limit, how much it is, + ** when its count starts again, and who can change it. + *******************************************************************************/ + static String stoppedMessage(Snapshot snapshot) + { + AssistantBudget budget = snapshot.budget(); + Instant end = periodEnd(budget.getPeriod(), snapshot.periodStart()); + return ("The assistant has reached the " + periodWord(budget.getPeriod()) + " limit" + (snapshot.limit() == null ? "" : " of " + amount(budget, snapshot.limit())) + + " of the budget '" + budget.getName() + "'. It starts again on " + RESET_DAY.format(LocalDate.ofInstant(end, ZoneOffset.UTC)) + + " at 00:00 UTC. An administrator can raise the limit or grant an override."); + } + + + /******************************************************************************* ** UTC period, usage, active overrides and effective state, without events. ** ROLE allowances are per permission holder, evaluated with their session. @@ -230,10 +326,8 @@ public class BudgetService { userId = "USER".equals(budget.getScopeType()) ? budget.getScopeValue() : userId; Instant now = clock.instant(); - LocalDate day = LocalDate.ofInstant(now, ZoneOffset.UTC); - LocalDate start = "MONTH".equals(budget.getPeriod()) ? day.withDayOfMonth(1) : day; - Instant from = start.atStartOfDay().toInstant(ZoneOffset.UTC); - Instant until = ("MONTH".equals(budget.getPeriod()) ? start.plusMonths(1) : start.plusDays(1)).atStartOfDay().toInstant(ZoneOffset.UTC); + Instant from = periodStart(budget.getPeriod(), now); + Instant until = periodEnd(budget.getPeriod(), from); String scopeKey = budget.getScopeType() + ":" + ("ROLE".equals(budget.getScopeType()) ? budget.getScopeValue() + ":" + userId : Objects.toString(budget.getScopeValue(), "")); boolean shared = List.of("INSTANCE", "PROVIDER", "MODEL").contains(budget.getScopeType()); String dateField = shared ? "occurredAt" : "createDate"; diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/home/AssistantHomeView.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/home/AssistantHomeView.java index 37fca91..0447d54 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/home/AssistantHomeView.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/home/AssistantHomeView.java @@ -686,7 +686,7 @@ public final class AssistantHomeView AssistantBudget budget = snapshot.budget(); html.append("
") .append(recordLink(prefix, AssistantBudget.TABLE_NAME, budget.getId(), budget.getName())).append(""); - html.append("

").append(esc(snapshot.scopeKey() + " · " + budget.getPeriod() + " (UTC) · " + budget.getMetric())).append("

"); + html.append("

").append(esc(snapshot.scopeKey() + " · " + budget.getPeriod() + ("WEEK".equals(budget.getPeriod()) ? " (UTC, from Monday) · " : " (UTC) · ") + budget.getMetric())).append("

"); html.append("

").append(esc(snapshot.used().stripTrailingZeros().toPlainString() + " / " + (snapshot.limit() == null ? "Unlimited" : snapshot.limit().stripTrailingZeros().toPlainString()) + " · " + snapshot.state())).append("

"); bar(html, snapshot.percent().doubleValue(), snapshot.percent().intValue() >= 100 ? RED diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantBudgetCustomizer.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantBudgetCustomizer.java index 8d15780..fb51c9a 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantBudgetCustomizer.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantBudgetCustomizer.java @@ -20,6 +20,7 @@ package com.kingsrook.qbits.assistant.metadata; import com.kingsrook.qbits.assistant.AssistantQBitConfig; import com.kingsrook.qbits.assistant.AssistantRuntime; import com.kingsrook.qbits.assistant.AssistantSecurity; +import com.kingsrook.qbits.assistant.engine.BudgetService; import com.kingsrook.qbits.assistant.model.AssistantBudget; import com.kingsrook.qbits.assistant.model.AssistantBudgetOverride; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; @@ -39,6 +40,9 @@ import java.util.Optional; /******************************************************************************* ** Validates budgets and attributes admin overrides to the current session. + ** Both are saved only by a session that holds assistant.admin: the tables' + ** permission rules refuse anybody else at the door, and this refuses a save + ** that reaches the table some other way. *******************************************************************************/ public class AssistantBudgetCustomizer implements TableCustomizerInterface { @@ -73,6 +77,10 @@ public class AssistantBudgetCustomizer implements TableCustomizerInterface *******************************************************************************/ private static void checkBudget(QRecord record, QRecord merged) throws QException { + if(!QContext.getQSession().hasPermission(AssistantQBitConfig.ADMIN_PERMISSION)) + { + error(record, "Only assistant.admin may create or change budgets."); + } defaults(record, merged, "enabled", true); defaults(record, merged, "warnPercent", 80); defaults(record, merged, "hardCeilingPercent", 120); @@ -80,7 +88,7 @@ public class AssistantBudgetCustomizer implements TableCustomizerInterface String scope = merged.getValueString("scopeType"); String value = merged.getValueString("scopeValue"); choice(record, merged, "scopeType", List.of("INSTANCE", "USER", "ROLE", "PROVIDER", "MODEL")); - choice(record, merged, "period", List.of("DAY", "MONTH")); + choice(record, merged, "period", BudgetService.PERIODS); choice(record, merged, "metric", List.of("COST_USD", "TOKENS")); choice(record, merged, "atLimitAction", List.of("DOWNGRADE", "STOP")); if("INSTANCE".equals(scope) ? value != null && !value.isBlank() : value == null || value.isBlank()) @@ -114,6 +122,11 @@ public class AssistantBudgetCustomizer implements TableCustomizerInterface { error(record, "Limit must be positive; token limits must be whole numbers."); } + BigDecimal ceiling = runtime.getConfig().getBudgetCostLimitCeilingUsd(); + if(limit != null && ceiling != null && "COST_USD".equals(merged.getValueString("metric")) && limit.compareTo(ceiling) > 0) + { + error(record, "A cost limit must not be more than " + ceiling.stripTrailingZeros().toPlainString() + " US dollars for one period."); + } int warn = merged.getValueInteger("warnPercent"); if(warn < 0 || warn >= 100 || merged.getValueInteger("hardCeilingPercent") < 100) { diff --git a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java index 3ec8ae7..070b005 100644 --- a/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java +++ b/qbit-assistant/src/main/java/com/kingsrook/qbits/assistant/metadata/AssistantRecordScreens.java @@ -141,7 +141,7 @@ public final class AssistantRecordScreens QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_registry_model"), QPossibleValueSource.newForTable(AssistantModel.TABLE_NAME).withName("assistant_budget_model"), budgetEnum("scopeType", "INSTANCE", "USER", "ROLE", "PROVIDER", "MODEL"), - budgetEnum("period", "DAY", "MONTH"), + budgetEnum("period", "DAY", "WEEK", "MONTH"), budgetEnum("metric", "COST_USD", "TOKENS"), budgetEnum("atLimitAction", "DOWNGRADE", "STOP"), new QPossibleValueSource().withName(EFFORT_PVS_NAME).withType(QPossibleValueSourceType.ENUM) @@ -1015,7 +1015,7 @@ public final class AssistantRecordScreens } table.getField("downgradeModelId").withPossibleValueSourceName("assistant_budget_model"); help(table, "scopeValue", "Empty for INSTANCE; user id, permission name, provider row id or model registry row id for the selected scope."); - help(table, "limitValue", "USD for COST_USD, or whole tokens for TOKENS. Periods use UTC."); + help(table, "limitValue", "USD for COST_USD, or whole tokens for TOKENS. Periods use UTC; a week starts on Monday."); help(table, "downgradeModelId", "Empty chooses the cheapest enabled eligible model, preferring LOCAL providers."); sections(table, section("budget", "Budget", "savings", Tier.T1, "id", "name", "enabled", "scopeType", "scopeValue", "period", "metric", "limitValue"), diff --git a/qbit-assistant/src/main/resources/help/qbit-assistant.yaml b/qbit-assistant/src/main/resources/help/qbit-assistant.yaml index 982a499..2d52369 100644 --- a/qbit-assistant/src/main/resources/help/qbit-assistant.yaml +++ b/qbit-assistant/src/main/resources/help/qbit-assistant.yaml @@ -1194,7 +1194,7 @@ entries: - key: table:assistant_budget;field:period summary: How long the count runs before it starts again at zero, in UTC. content: | - **DAY** starts at midnight UTC. **MONTH** starts at midnight UTC on the first of the month. UTC may differ from your local calendar day. + **DAY** starts at midnight UTC. **WEEK** starts at midnight UTC on Monday. **MONTH** starts at midnight UTC on the first of the month. UTC may differ from your local calendar day. - key: table:assistant_budget;field:notes summary: Optional note about why the budget exists, up to {{maxLength:assistant_budget.notes}} characters. - key: table:assistant_budget;field:hardCeilingPercent @@ -1222,7 +1222,7 @@ entries: - key: table:assistant_budget;field:limitValue summary: 'The size of the limit: US dollars for cost budgets, whole tokens for token budgets.' content: | - Must be greater than zero. Overrides add to this number. + Must be greater than zero. A cost limit above the largest amount this site allows for one period is refused when you save. Overrides add to this number. A change applies to the next question; nothing needs restarting. - key: table:assistant_budget_override;slot:help summary: A temporary extra allowance or lifted limit on one budget, granted by an assistant admin. content: | diff --git a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/BudgetServiceTest.java b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/BudgetServiceTest.java index e3f0f2c..c96e234 100644 --- a/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/BudgetServiceTest.java +++ b/qbit-assistant/src/test/java/com/kingsrook/qbits/assistant/engine/BudgetServiceTest.java @@ -46,8 +46,12 @@ import com.kingsrook.qqq.backend.core.alerts.AlertSeverity; import com.kingsrook.qqq.backend.core.alerts.AlertSignal; import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData; import com.kingsrook.qqq.backend.core.alerts.LoggingAlertSink; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.permissions.TablePermissionSubType; import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; @@ -338,6 +342,185 @@ class BudgetServiceTest extends BaseAssistantTest + /******************************************************************************* + ** Everyone's weekly spending limit, 500 US dollars, refusing at the limit. + *******************************************************************************/ + private AssistantBudget weekly() throws Exception + { + return (store().insert(new AssistantBudget().withName("Everyone, a week").withScopeType("INSTANCE").withPeriod("WEEK") + .withMetric("COST_USD").withLimitValue(BigDecimal.valueOf(500)).withAtLimitAction("STOP").withHardCeilingPercent(100))); + } + + + + /******************************************************************************* + ** The evaluator at a given instant. + *******************************************************************************/ + private BudgetService serviceAt(String instant) + { + return (new BudgetService(store(), config.getRuntime().getProviderService(), Clock.fixed(Instant.parse(instant), ZoneOffset.UTC))); + } + + + + /******************************************************************************* + ** A week is the ISO week in UTC: Monday 00:00:00 UTC up to the next Monday + ** 00:00:00 UTC. NOW is Tuesday 2026-10-13, so its week starts on Monday + ** 2026-10-12. The last second of Sunday belongs to the week before; the + ** first instant of the next Monday belongs to the week after. + *******************************************************************************/ + @Test + void weekRunsFromMondayToMondayInUtc() throws Exception + { + AssistantBudget budget = weekly(); + usage(1_000_000, Instant.parse("2026-10-11T23:59:59Z")); + usage(20_000_000, Instant.parse("2026-10-12T00:00:00Z")); + usage(300_000_000, Instant.parse("2026-10-18T23:59:59Z")); + usage(4_000_000_000L, Instant.parse("2026-10-19T00:00:00Z")); + + BudgetService.Snapshot week = service(new ArrayList<>()).snapshot(budget, USER); + assertThat(week.periodStart()).isEqualTo(Instant.parse("2026-10-12T00:00:00Z")); + assertThat(BudgetService.periodEnd("WEEK", week.periodStart())).isEqualTo(Instant.parse("2026-10-19T00:00:00Z")); + assertThat(week.used()).as("Monday 00:00:00 through Sunday 23:59:59 UTC").isEqualByComparingTo("320"); + + assertThat(serviceAt("2026-10-11T23:59:59Z").snapshot(budget, USER).used()).as("the week before").isEqualByComparingTo("1"); + assertThat(serviceAt("2026-10-18T23:59:59Z").snapshot(budget, USER).periodStart()).isEqualTo(Instant.parse("2026-10-12T00:00:00Z")); + BudgetService.Snapshot next = serviceAt("2026-10-19T00:00:00Z").snapshot(budget, USER); + assertThat(next.periodStart()).as("the count starts again on Monday at 00:00 UTC").isEqualTo(Instant.parse("2026-10-19T00:00:00Z")); + assertThat(next.used()).isEqualByComparingTo("4000"); + + // Sunday 21:00 in Chicago is already Monday in UTC: the week is UTC's, not the reader's + assertThat(BudgetService.periodStart("WEEK", Instant.parse("2026-10-19T02:00:00Z"))).isEqualTo(Instant.parse("2026-10-19T00:00:00Z")); + assertThat(BudgetService.periodStart("WEEK", Instant.parse("2026-12-31T12:00:00Z"))).as("a week spans the turn of a month and a year") + .isEqualTo(Instant.parse("2026-12-28T00:00:00Z")); + assertThat(BudgetService.periodEnd("WEEK", Instant.parse("2026-12-28T00:00:00Z"))).isEqualTo(Instant.parse("2027-01-04T00:00:00Z")); + assertThat(BudgetService.periodStart("DAY", NOW)).isEqualTo(Instant.parse("2026-10-13T00:00:00Z")); + assertThat(BudgetService.periodStart("MONTH", NOW)).isEqualTo(Instant.parse("2026-10-01T00:00:00Z")); + assertThat(BudgetService.periodEnd("MONTH", Instant.parse("2026-10-01T00:00:00Z"))).isEqualTo(Instant.parse("2026-11-01T00:00:00Z")); + } + + + + /******************************************************************************* + ** Cost is counted in micro-USD, millionths of a dollar, and a limit is in + ** dollars: 499,999,999 micros are 499.999999 dollars, below a 500 dollar + ** limit; one micro more is the limit. At the limit the refusal says which + ** limit, how much, when it starts again and who can change it; a week later + ** nothing is refused. + *******************************************************************************/ + @Test + void weeklyLimitRefusesAtFiveHundredDollarsAndStartsAgainOnMonday() throws Exception + { + AssistantBudget budget = weekly(); + usage(499_999_999L, NOW); + BudgetService service = service(new ArrayList<>()); + BudgetService.Snapshot below = service.snapshot(budget, USER); + assertThat(below.used()).isEqualByComparingTo("499.999999"); + assertThat(below.state()).as("above the 80 percent warning, below the limit").isEqualTo("WARNED"); + BudgetService.Decision warned = service.evaluate(USER, null, false, false); + assertThat(warned.stopped()).isFalse(); + assertThat(warned.notices()).containsExactly("You have used 99% of the weekly assistant budget 'Everyone, a week'."); + + usage(1L, NOW); + assertThat(service.snapshot(budget, USER).used()).isEqualByComparingTo("500"); + BudgetService.Decision stopped = service.evaluate(USER, null, false, false); + assertThat(stopped.stopped()).isTrue(); + assertThat(stopped.reason()).isEqualTo("The assistant has reached the weekly limit of $500.00 of the budget 'Everyone, a week'." + + " It starts again on Monday 19 October 2026 at 00:00 UTC. An administrator can raise the limit or grant an override."); + + assertThat(serviceAt("2026-10-19T00:00:00Z").evaluate(USER, null, false, false).stopped()).as("the next week").isFalse(); + assertThat(BudgetService.amount(budget, new BigDecimal("1234.5"))).isEqualTo("$1,234.50"); + assertThat(BudgetService.amount(new AssistantBudget().withMetric("TOKENS"), new BigDecimal("1500000"))).isEqualTo("1,500,000 tokens"); + } + + + + /******************************************************************************* + ** A person who asks at the limit is refused before any model call: HTTP 429, + ** code BUDGET, and the sentence. + *******************************************************************************/ + @Test + void aQuestionAtTheWeeklyLimitIsRefusedWithTheReason() throws Exception + { + weekly(); + usage(500_000_000L, Instant.now()); + assertThatThrownBy(() -> runner.run(USER, "Hello")).isInstanceOf(AssistantApiException.class) + .hasMessageStartingWith("The assistant has reached the weekly limit of $500.00 of the budget 'Everyone, a week'. It starts again on Monday ") + .hasMessageEndingWith(" at 00:00 UTC. An administrator can raise the limit or grant an override.").satisfies(e -> + { + assertThat(((AssistantApiException) e).getStatus()).isEqualTo(429); + assertThat(((AssistantApiException) e).getCode()).isEqualTo("BUDGET"); + }); + } + + + + /******************************************************************************* + ** The limit is read from its row at every evaluation: an administrator's + ** change applies to the next one, on the same running service. + *******************************************************************************/ + @Test + void aChangedLimitAppliesToTheNextEvaluation() throws Exception + { + AssistantBudget budget = weekly(); + usage(500_000_000L, NOW); + BudgetService service = service(new ArrayList<>()); + assertThat(service.evaluate(USER, null, false, false).stopped()).isTrue(); + + store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("limitValue", BigDecimal.valueOf(1000))); + BudgetService.Decision raised = service.evaluate(USER, null, false, false); + assertThat(raised.stopped()).as("raised to 1,000: 500 spent is half").isFalse(); + assertThat(raised.notices()).isEmpty(); + + store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("limitValue", BigDecimal.valueOf(100))); + assertThat(service.evaluate(USER, null, false, false).reason()).as("lowered to 100").contains("weekly limit of $100.00"); + + store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("enabled", false)); + assertThat(service.evaluate(USER, null, false, false).stopped()).as("turned off").isFalse(); + } + + + + /******************************************************************************* + ** A budget is saved only by an administrator and only with a sane limit: a + ** known period, a positive number, and a cost limit no larger than the + ** ceiling the host configured. + *******************************************************************************/ + @Test + void onlyAnAdministratorSavesABudgetAndOnlyASaneOne() throws Exception + { + AssistantBudget budget = weekly(); + assertThatThrownBy(() -> store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("period", "FORTNIGHT"))) + .hasMessageContaining("Invalid period"); + assertThatThrownBy(() -> store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("limitValue", BigDecimal.ZERO))) + .hasMessageContaining("Limit must be positive"); + assertThatThrownBy(() -> store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("limitValue", new BigDecimal("-5")))) + .hasMessageContaining("Limit must be positive"); + assertThatThrownBy(() -> store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("limitValue", new BigDecimal("1000000.01")))) + .hasMessageContaining("must not be more than 1000000 US dollars"); + config.setBudgetCostLimitCeilingUsd(new BigDecimal("10000")); + assertThatThrownBy(() -> store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("limitValue", new BigDecimal("50000")))) + .hasMessageContaining("must not be more than 10000 US dollars"); + store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("limitValue", new BigDecimal("10000"))); + assertThat(store().budget(budget.getId()).getLimitValue()).isEqualByComparingTo("10000"); + AssistantBudget tokens = store().insert(new AssistantBudget().withName("Tokens").withScopeType("INSTANCE").withPeriod("WEEK") + .withMetric("TOKENS").withLimitValue(new BigDecimal("5000000000"))); + assertThat(tokens.getId()).as("the dollar ceiling does not apply to a token limit").isNotNull(); + + AssistantStore store = store(); + QContext.init(qInstance, AssistantTestFixture.ownerSession(USER, config)); + assertThatThrownBy(() -> PermissionsHelper.checkTablePermissionThrowing(new UpdateInput(AssistantBudget.TABLE_NAME), TablePermissionSubType.EDIT)) + .as("the table refuses a person without assistant.admin").isInstanceOf(QPermissionDeniedException.class); + assertThatThrownBy(() -> store.updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("limitValue", new BigDecimal("9000")))) + .hasMessageContaining("assistant.admin"); + assertThatThrownBy(() -> store.insert(new AssistantBudget().withName("Mine").withScopeType("INSTANCE").withPeriod("WEEK") + .withMetric("COST_USD").withLimitValue(BigDecimal.TEN))).hasMessageContaining("assistant.admin"); + QContext.init(qInstance, AssistantTestFixture.ownerSession("admin-budget", config)); + assertThat(store.budget(budget.getId()).getLimitValue()).as("the limit is as the administrator left it").isEqualByComparingTo("10000"); + } + + + /******************************************************************************* ** A stop dominates a warning or downgrade in another applicable budget. *******************************************************************************/