release: merge develop into release/0.1 (weekly budgets, refusal sentence, limit ceiling) #101
No files matched your search
@@ -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
|
||||
*******************************************************************************/
|
||||
|
||||
+100
-6
@@ -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";
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+14
-1
@@ -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)
|
||||
{
|
||||
|
||||
+2
-2
@@ -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: |
|
||||
|
||||
+183
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user