refactor: budget alerts through core QAlerts on QQQ RC.5 (#28) #31
No files matched your search
@@ -11,6 +11,8 @@
|
||||
|
||||
## Unreleased - P-d
|
||||
|
||||
- Require QQQ RC.5 and send budget alerts directly through core QAlerts, with admin-visible alert type metadata and core sink tests.
|
||||
|
||||
- Admin budgets, expiring overrides, deduplicated warning/downgrade/stop events.
|
||||
- Per-dispatch limits preserving PHI routing and existing caps.
|
||||
- Home budget cards, 30-day cost/token bars and provider breakdowns.
|
||||
|
||||
@@ -385,7 +385,7 @@ Apache 2.0.
|
||||
|
||||
## Data classification and HIPAA mode
|
||||
|
||||
On QQQ 4.1.0-RC.4, classify application tables/fields in the host configuration:
|
||||
On QQQ 4.1.0-RC.5, classify application tables/fields in the host configuration:
|
||||
|
||||
```java
|
||||
import com.kingsrook.qbits.assistant.classification.DataClassification;
|
||||
@@ -455,7 +455,7 @@ extra allowance or temporary unlimited use from the budget's Overrides child lis
|
||||
with a required reason and expiry; the session sets the grantor.
|
||||
|
||||
Budget state changes appear once per scope/period/state in the Events child list,
|
||||
as panel notices and, when the optional QQQ Alerts SPI is installed, bell alerts.
|
||||
as panel notices and, through core QAlerts, bell alerts when an alerts sink is installed (logging otherwise).
|
||||
The Assistant home shows budgets, overrides, daily cost and tokens, and breakdowns
|
||||
by user, model, provider and tool. Existing `AssistantBudgets`/policy limits and
|
||||
provider monthly caps continue to apply independently of these rows and overrides.
|
||||
|
||||
@@ -28,6 +28,8 @@ All configuration remains assistant.admin-only, with AI exposure NONE.
|
||||
|
||||
## P-d: data-driven budgets
|
||||
|
||||
Requires QQQ 4.1.0-RC.5 for the core Alerts API and alert type metadata.
|
||||
|
||||
Create `assistant_budget`, `assistant_budget_override`, `assistant_budget_event`
|
||||
and `assistant_budget_usage`
|
||||
using the P-d DDL in [schema-postgres.sql](docs/schema-postgres.sql) before deploying.
|
||||
|
||||
+4
-2
@@ -387,8 +387,10 @@ AI exposure NONE is not enough, because the assistant acts as the user and an ad
|
||||
reason, session grantor and expiry; they never waive the other limit layers.
|
||||
- Start-turn checks use the existing 429 BUDGET path through PolicyService.
|
||||
Every dispatch, including strict retries, evaluates current usage and overrides.
|
||||
Events deduplicate WARNED, DOWNGRADED and STOPPED; optional reflective QAlerts
|
||||
signals accompany panel notices without a compile-time Alerts dependency.
|
||||
Events deduplicate WARNED, DOWNGRADED and STOPPED.
|
||||
- QQQ RC.5 core QAlerts sends budget signals alongside panel notices, with three
|
||||
admin-visible alert types and a default logging sink. Resolution awaits active
|
||||
alert/recurrence tracking so overrides cannot clear shared alerts incorrectly.
|
||||
- Explicit registry model selection leaves provider defaults untouched. Automatic
|
||||
downgrades prefer LOCAL, then price. PHI eligibility and destination provider caps
|
||||
still apply; a missing eligible target stops the turn.
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
</build>
|
||||
|
||||
<profiles>
|
||||
<!-- The assistant needs QQQ 4.1.0-RC.4: the AI tool kernel (package -->
|
||||
<!-- The assistant needs QQQ 4.1.0-RC.5: the AI tool kernel (package -->
|
||||
<!-- actions.ai, qqq#1078) and the developer panels (qqq#1076). Until -->
|
||||
<!-- qbit-build-parent imports the 4.1 BOM, this -->
|
||||
<!-- profile is active by default and imports qqq-bom-pom ahead of the -->
|
||||
@@ -205,7 +205,7 @@
|
||||
<activeByDefault>true</activeByDefault>
|
||||
</activation>
|
||||
<properties>
|
||||
<qqq.snapshot.version>4.1.0-RC.4</qqq.snapshot.version>
|
||||
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
|
||||
</properties>
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
|
||||
@@ -28,6 +28,9 @@ import com.kingsrook.qbits.assistant.metadata.AssistantRecordScreens;
|
||||
import com.kingsrook.qbits.assistant.processes.DiscoverModelsProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.TestConnectionProcess;
|
||||
import com.kingsrook.qbits.assistant.processes.TestModelConnectionProcess;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantBudget;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
@@ -155,6 +158,9 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
qInstance.addTable(customized);
|
||||
tables.add(customized);
|
||||
}
|
||||
qInstance.add(budgetAlertType("warning", "Assistant budget warning", "Assistant usage has reached the budget warning threshold.", AlertSeverity.WARNING));
|
||||
qInstance.add(budgetAlertType("downgraded", "Assistant budget downgraded", "Assistant usage has reached the budget limit and switched to a cheaper model.", AlertSeverity.WARNING));
|
||||
qInstance.add(budgetAlertType("stopped", "Assistant budget stopped", "Assistant dispatch has stopped because a budget limit was reached.", AlertSeverity.ERROR));
|
||||
qInstance.addPossibleValueSource(builder.buildConversationPossibleValueSource());
|
||||
AssistantRecordScreens.buildPossibleValueSources().forEach(qInstance::addPossibleValueSource);
|
||||
AssistantRecordScreens.buildSettingsPossibleValueSources(qBitConfig).forEach(qInstance::addPossibleValueSource);
|
||||
@@ -185,6 +191,18 @@ public class AssistantQBitProducer implements QBitMetaDataProducer<AssistantQBit
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Declare budget alerts as top-level core metadata for admin visibility.
|
||||
*******************************************************************************/
|
||||
private QAlertTypeMetaData budgetAlertType(String state, String label, String description, AlertSeverity severity)
|
||||
{
|
||||
return (new QAlertTypeMetaData().withName("assistant.budget." + state).withLabel(label).withDescription(description)
|
||||
.withDefaultSeverity(severity).withHowToFix("Raise the budget or grant an override in Assistant → Budgets")
|
||||
.withSourceQBitName(ARTIFACT_ID).withVisibilityPermission("assistant.admin").withSubjectTableName(AssistantBudget.TABLE_NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Build the runtime and register the routes.
|
||||
*******************************************************************************/
|
||||
|
||||
+7
-59
@@ -17,23 +17,16 @@
|
||||
package com.kingsrook.qbits.assistant.engine;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import java.lang.reflect.Method;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Optional Alerts producer SPI, with log-only fallback and no dependency.
|
||||
** Budget signals through core's non-throwing Alerts API and default logging sink.
|
||||
*******************************************************************************/
|
||||
public final class BudgetAlerts
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(BudgetAlerts.class);
|
||||
private static final String QALERTS = "com.kingsrook.qqq.backend.core.alerts.QAlerts";
|
||||
private static final String ALERT_SIGNAL = "com.kingsrook.qqq.backend.core.alerts.AlertSignal";
|
||||
private static final String SEVERITY = "com.kingsrook.qqq.backend.core.alerts.AlertSeverity";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
@@ -44,55 +37,10 @@ public final class BudgetAlerts
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** True when the Alerts SPI is on the classpath.
|
||||
** Raise one deduplicated budget transition through the configured core sink.
|
||||
*******************************************************************************/
|
||||
public static boolean available()
|
||||
public static void raise(String type, String dedupKey, AlertSeverity severity, String title, String summary)
|
||||
{
|
||||
try
|
||||
{
|
||||
Class.forName(QALERTS, false, BudgetAlerts.class.getClassLoader());
|
||||
return (true);
|
||||
}
|
||||
catch(ClassNotFoundException | LinkageError e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QAlerts.raise(new AlertSignal(type).withDedupKey(dedupKey).withSeverity(severity).withTitle(title).withSummary(summary));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QAlerts.raise(new AlertSignal(type).withDedupKey(..).withSeverity(..)
|
||||
** .withTitle(..).withSummary(..)), or a log line.
|
||||
*******************************************************************************/
|
||||
public static void raise(String type, String dedupKey, String severity, String title, String summary)
|
||||
{
|
||||
LOG.info("Assistant budget alert", logPair("type", type), logPair("dedupKey", dedupKey), logPair("title", title));
|
||||
if(!available())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
ClassLoader loader = BudgetAlerts.class.getClassLoader();
|
||||
Class<?> signalType = Class.forName(ALERT_SIGNAL, true, loader);
|
||||
Object signal = signalType.getConstructor(String.class).newInstance(type);
|
||||
signal = signalType.getMethod("withDedupKey", String.class).invoke(signal, dedupKey);
|
||||
Class<?> severityType = Class.forName(SEVERITY, true, loader);
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
Object level = Enum.valueOf((Class<? extends Enum>) severityType.asSubclass(Enum.class), severity);
|
||||
signal = signalType.getMethod("withSeverity", severityType).invoke(signal, level);
|
||||
signal = signalType.getMethod("withTitle", String.class).invoke(signal, title);
|
||||
signal = signalType.getMethod("withSummary", String.class).invoke(signal, summary);
|
||||
Method raise = Class.forName(QALERTS, true, loader).getMethod("raise", signalType);
|
||||
raise.invoke(null, signal);
|
||||
}
|
||||
catch(Exception | LinkageError e)
|
||||
{
|
||||
LOG.info("Could not raise a assistant budget alert through the Alerts SPI", logPair("type", type), logPair("reason", e.getClass().getSimpleName()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
+7
-28
@@ -24,6 +24,7 @@ import com.kingsrook.qbits.assistant.model.AssistantBudgetUsage;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantModel;
|
||||
import com.kingsrook.qbits.assistant.model.AssistantUsage;
|
||||
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
@@ -48,21 +49,6 @@ public class BudgetService
|
||||
private final AssistantStore store;
|
||||
private final ProviderService providers;
|
||||
private final Clock clock;
|
||||
private final AlertRaiser alerts;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Injectable alert seam; production uses the optional Alerts producer SPI.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface AlertRaiser
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Attempt to raise one deduplicated transition.
|
||||
*******************************************************************************/
|
||||
void raise(String type, String key, String severity, String title, String summary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -104,21 +90,10 @@ public class BudgetService
|
||||
** Production constructor.
|
||||
*******************************************************************************/
|
||||
public BudgetService(AssistantStore store, ProviderService providers, Clock clock)
|
||||
{
|
||||
this(store, providers, clock, BudgetAlerts::raise);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor with an alert seam for tests.
|
||||
*******************************************************************************/
|
||||
public BudgetService(AssistantStore store, ProviderService providers, Clock clock, AlertRaiser alerts)
|
||||
{
|
||||
this.store = store;
|
||||
this.providers = providers;
|
||||
this.clock = clock;
|
||||
this.alerts = alerts;
|
||||
}
|
||||
|
||||
|
||||
@@ -318,6 +293,10 @@ public class BudgetService
|
||||
|
||||
/*******************************************************************************
|
||||
** The persisted unique key is also the warning/alert deduplication record.
|
||||
** Resolution on period rollover or override recovery needs an active-alert
|
||||
** lifecycle: these immutable events cannot re-raise a resolved state within
|
||||
** the same period, and user-specific overrides must not resolve shared alerts.
|
||||
** Track active keys and recurrence before calling QAlerts.resolve here.
|
||||
*******************************************************************************/
|
||||
private boolean transition(Snapshot snapshot, String state, String userId, String message) throws QException
|
||||
{
|
||||
@@ -333,8 +312,8 @@ public class BudgetService
|
||||
case "DOWNGRADED" -> "downgraded";
|
||||
default -> "stopped";
|
||||
};
|
||||
alerts.raise(type, snapshot.budget().getId() + ":" + snapshot.scopeKey() + ":" + snapshot.periodStart(),
|
||||
"STOPPED".equals(state) ? "ERROR" : "WARNING", "Assistant budget " + snapshot.budget().getName(), message);
|
||||
BudgetAlerts.raise(type, snapshot.budget().getId() + ":" + snapshot.scopeKey() + ":" + snapshot.periodStart(),
|
||||
"STOPPED".equals(state) ? AlertSeverity.ERROR : AlertSeverity.WARNING, "Assistant budget " + snapshot.budget().getName(), message);
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
+25
@@ -26,6 +26,9 @@ import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionCheckResult;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
@@ -103,6 +106,28 @@ class AssistantQBitProducerTest extends BaseAssistantTest
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Budget types are discoverable through core with admin-only visibility.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void budgetAlertTypes()
|
||||
{
|
||||
var types = AlertsInstanceMetaData.of(qInstance).getAlertTypes();
|
||||
assertThat(types).containsOnlyKeys("assistant.budget.warning", "assistant.budget.downgraded", "assistant.budget.stopped");
|
||||
for(QAlertTypeMetaData type : types.values())
|
||||
{
|
||||
assertThat(type.getLabel()).isNotBlank();
|
||||
assertThat(type.getDescription()).isNotBlank();
|
||||
assertThat(type.getDefaultSeverity()).isEqualTo(type.getName().endsWith("stopped") ? AlertSeverity.ERROR : AlertSeverity.WARNING);
|
||||
assertThat(type.getHowToFix()).isEqualTo("Raise the budget or grant an override in Assistant → Budgets");
|
||||
assertThat(type.getSourceQBitName()).isEqualTo("assistant");
|
||||
assertThat(type.getVisibilityPermission()).isEqualTo("assistant.admin");
|
||||
assertThat(type.getSubjectTableName()).isEqualTo("assistant_budget");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The admin app and tables: visible only with assistant.admin.
|
||||
*******************************************************************************/
|
||||
|
||||
+59
-8
@@ -35,6 +35,13 @@ import com.kingsrook.qbits.assistant.provider.AssistantProviderType;
|
||||
import com.kingsrook.qbits.assistant.provider.ModelUsage;
|
||||
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
|
||||
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
|
||||
import com.kingsrook.qqq.backend.core.alerts.AlertsInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.alerts.LoggingAlertSink;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
@@ -172,12 +179,45 @@ class BudgetServiceTest extends BaseAssistantTest
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fixed-clock evaluator and alert seam.
|
||||
** Fixed-clock evaluator with the production core sink configuration.
|
||||
*******************************************************************************/
|
||||
private BudgetService service(List<String> alerts)
|
||||
private BudgetService service(List<AlertSignal> alerts)
|
||||
{
|
||||
return (new BudgetService(store(), config.getRuntime().getProviderService(), Clock.fixed(NOW, ZoneOffset.UTC),
|
||||
(type, key, severity, title, summary) -> alerts.add(type + ":" + severity)));
|
||||
AlertsInstanceMetaData.ofOrWithNew(qInstance).withSink(new QCodeReferenceWithProperties(TestAlertSink.class, Map.of("signals", (ArrayList<AlertSignal>) alerts)));
|
||||
return (new BudgetService(store(), config.getRuntime().getProviderService(), Clock.fixed(NOW, ZoneOffset.UTC)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Core loads a separate sink per instance; captured signals stay test-local.
|
||||
*******************************************************************************/
|
||||
public static class TestAlertSink extends LoggingAlertSink implements InitializableViaCodeReference
|
||||
{
|
||||
private List<AlertSignal> signals;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Receive this test's capture list from the configured code reference.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void initialize(QCodeReference codeReference)
|
||||
{
|
||||
signals = (List<AlertSignal>) ((QCodeReferenceWithProperties) codeReference).getProperties().get("signals");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture the signal delivered through core.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void raise(AlertSignal signal)
|
||||
{
|
||||
signals.add(signal);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,15 +228,22 @@ class BudgetServiceTest extends BaseAssistantTest
|
||||
@Test
|
||||
void warningBoundaryAndDedup() throws Exception
|
||||
{
|
||||
budget();
|
||||
AssistantBudget budget = budget();
|
||||
usage(790_000, NOW);
|
||||
List<String> alerts = new ArrayList<>();
|
||||
List<AlertSignal> alerts = new ArrayList<>();
|
||||
BudgetService service = service(alerts);
|
||||
assertThat(service.evaluate(USER, null, false, false).notices()).isEmpty();
|
||||
usage(10_000, NOW);
|
||||
assertThat(service.evaluate(USER, null, false, false).notices()).singleElement().asString().contains("80%", "Per user daily");
|
||||
assertThat(service.evaluate(USER, null, false, false).notices()).isEmpty();
|
||||
assertThat(alerts).containsExactly("assistant.budget.warning:WARNING");
|
||||
assertThat(alerts).singleElement().satisfies(signal ->
|
||||
{
|
||||
assertThat(signal.getAlertTypeName()).isEqualTo("assistant.budget.warning");
|
||||
assertThat(signal.getDedupKey()).isEqualTo(budget.getId() + ":USER:" + USER + ":2026-10-13T00:00:00Z");
|
||||
assertThat(signal.getSeverity()).isEqualTo(AlertSeverity.WARNING);
|
||||
assertThat(signal.getTitle()).isEqualTo("Assistant budget Per user daily");
|
||||
assertThat(signal.getSummary()).contains("80%", "Per user daily");
|
||||
});
|
||||
assertThat(store().query(AssistantBudgetEvent.TABLE_NAME, new QQueryFilter(), AssistantBudgetEvent.class)).singleElement()
|
||||
.extracting(AssistantBudgetEvent::getState).isEqualTo("WARNED");
|
||||
}
|
||||
@@ -215,10 +262,14 @@ class BudgetServiceTest extends BaseAssistantTest
|
||||
AssistantModel cheap = store().insert(new AssistantModel().withProviderId(local.getId()).withModelId("cheap").withEnabled(true).withInputUsdPerMTok(BigDecimal.ONE));
|
||||
store().insert(new AssistantModel().withProviderId(cloud.getId()).withModelId("main").withEnabled(true).withInputUsdPerMTok(BigDecimal.ZERO));
|
||||
usage(1_000_000, NOW);
|
||||
BudgetService service = service(new ArrayList<>());
|
||||
List<AlertSignal> alerts = new ArrayList<>();
|
||||
BudgetService service = service(alerts);
|
||||
ProviderService.Active active = config.getRuntime().getProviderService().resolve("main");
|
||||
assertThat(service.evaluate(USER, active, false, false).active().providerId()).isEqualTo(local.getId());
|
||||
assertThat(service.evaluate(USER, active, true, false).stopped()).isTrue();
|
||||
assertThat(alerts).extracting(AlertSignal::getAlertTypeName).containsExactly("assistant.budget.downgraded", "assistant.budget.stopped");
|
||||
assertThat(alerts).extracting(AlertSignal::getSeverity).containsExactly(AlertSeverity.WARNING, AlertSeverity.ERROR);
|
||||
assertThat(alerts).extracting(AlertSignal::getDedupKey).containsOnly(budget.getId() + ":USER:" + USER + ":2026-10-13T00:00:00Z");
|
||||
store().updateFields(AssistantBudget.TABLE_NAME, new QRecord().withValue("id", budget.getId()).withValue("downgradeModelId", cheap.getId()));
|
||||
assertThat(service.evaluate(USER, active, false, false).active().model()).isEqualTo("cheap");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user