release: merge develop into release/0.1 (weekly budgets, refusal sentence, limit ceiling) #101

Merged
james.maes merged 3 commits from rc/develop-into-release-0.1-weekly into release/0.1 2026-11-11 16:15:56 +00:00
8 changed files with 347 additions and 14 deletions

No files matched your search

+5 -2
View File
@@ -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.
@@ -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<String> 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
*******************************************************************************/
@@ -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<String> 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";
@@ -686,7 +686,7 @@ public final class AssistantHomeView
AssistantBudget budget = snapshot.budget();
html.append("<div style=\"").append(ROW).append("\"><strong>")
.append(recordLink(prefix, AssistantBudget.TABLE_NAME, budget.getId(), budget.getName())).append("</strong>");
html.append("<p>").append(esc(snapshot.scopeKey() + " · " + budget.getPeriod() + " (UTC) · " + budget.getMetric())).append("</p>");
html.append("<p>").append(esc(snapshot.scopeKey() + " · " + budget.getPeriod() + ("WEEK".equals(budget.getPeriod()) ? " (UTC, from Monday) · " : " (UTC) · ") + budget.getMetric())).append("</p>");
html.append("<p>").append(esc(snapshot.used().stripTrailingZeros().toPlainString() + " / "
+ (snapshot.limit() == null ? "Unlimited" : snapshot.limit().stripTrailingZeros().toPlainString()) + " · " + snapshot.state())).append("</p>");
bar(html, snapshot.percent().doubleValue(), snapshot.percent().intValue() >= 100 ? RED
@@ -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)
{
@@ -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"),
@@ -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: |
@@ -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.
*******************************************************************************/