fix(defaults): portable Liquibase preconditions; seeds never change an existing host #50

Merged
KofTwentyTwo merged 1 commits from fix/assistant-defaults-guards into develop 2026-10-14 21:58:38 +00:00
8 changed files with 171 additions and 41 deletions

No files matched your search

+3
View File
@@ -2,6 +2,9 @@
## Unreleased - Starter defaults
- Fix the defaults changelog: every precondition is a portable `sql-check` (the formatted-SQL `table-exists` form failed to parse); the policy, budget and prices are seeded only into empty tables and the profile only when no default exists, so an existing host never changes on deploy. The test now runs the changelog through Liquibase 4.33, including an already configured host and a missing table.
- The Setup card prices the default model the way calls are costed (registry, price rows, configured prices); "Add rows for every table" reads fresh data-access rows.
- Ship `db/liquibase/qbit-assistant/assistant-defaults.changelog.sql`: insert-only starter rows for the policy, a default profile, four starter skills offered through it, a monthly instance budget (warn, downgrade, stop), current Anthropic prices and a disabled example scheduled task.
- Add the Setup card to the Assistant home: every configuration table with its state (set up, needs setup, using defaults, optional) and the next step.
- Add the `assistant_data_access` table action "Add rows for every table", which writes the default access out as editable rows.
+9 -7
View File
@@ -10,13 +10,15 @@ the `assistant_*` tables:
- include: { file: db/liquibase/qbit-assistant/assistant-defaults.changelog.sql }
```
It is insert-only (every row is skipped when its name or key exists, so edits
survive) and guarded by preconditions. It adds: one `default` policy row (only
when the host has none), a `default` profile, four starter skills linked to it,
a **$50/month instance budget** (warn 80%, downgrade 100%, stop 120%), the
current Anthropic list prices and a disabled example scheduled task. The budget
is enforced from the next question: raise its limit, or turn it off, before
deploying if $50 a month is too low for the host.
It is insert-only and never changes a host that already configured the
assistant: the policy, the **$50/month instance budget** (warn 80%, downgrade
100%, stop 120%) and the Anthropic list prices are seeded only into empty tables,
and the `default` profile only when no default profile exists. Price rows take
precedence over `AssistantQBitConfig.withPricing`, which is why they are seeded
only when there are none. The four starter skills and a disabled example
scheduled task are always added (by name); on a host with its own default
profile the skills are not linked to it, and the Setup card says so. Requires
Liquibase 4.x or later (preconditions are `sql-check`s).
No DDL changes. New: the Assistant home's Setup card, the `assistant_data_access`
table action **Add rows for every table** (`assistantDataAccessAddDefaults`,
+7
View File
@@ -80,6 +80,13 @@
<version>2.3.232</version>
<scope>test</scope>
</dependency>
<!-- ...through real Liquibase (the version kof22 runs), so preconditions and bookkeeping are exercised. -->
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.33.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -22,6 +22,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qbits.assistant.AssistantRuntime;
import com.kingsrook.qbits.assistant.engine.ProviderService;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
@@ -33,6 +34,7 @@ import com.kingsrook.qbits.assistant.model.AssistantRagSource;
import com.kingsrook.qbits.assistant.model.AssistantScheduledTask;
import com.kingsrook.qbits.assistant.model.AssistantSkill;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.spi.ModelPricing;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
@@ -103,17 +105,28 @@ public final class AssistantSetupStatus
items.add(new Item("Provider", AssistantProvider.TABLE_NAME, State.TODO, "The default provider cannot be used.", "Turn one on, set it as default, make sure its key variable is set, then run Test connection."));
}
///////////////////////////////////////////////////////////////
// prices: the active cloud model needs one, or it costs $0 //
///////////////////////////////////////////////////////////////
List<AssistantModelPrice> prices = store.query(AssistantModelPrice.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantModelPrice.class);
boolean activePriced = active == null || "LOCAL".equals(active.location()) || prices.stream().anyMatch(p ->
p.getProviderType() != null && p.getProviderType().equals(active.row().getProviderType()) && p.getModel() != null && p.getModel().equals(active.row().getModel()));
items.add(prices.isEmpty()
? new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.TODO, "No prices: every call costs $0, so cost budgets never trigger.", "Add the list price of each cloud model you use.")
: activePriced
? new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.OK, prices.size() + " model price" + (prices.size() == 1 ? "" : "s") + ".", null)
: new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.TODO, "The default model (" + active.row().getModel() + ") has no price, so its calls cost $0.", "Add a price row for it."));
//////////////////////////////////////////////////////////////////////
// prices: resolved exactly as calls are costed (registry row, price //
// row, then the configured price table); local models cost nothing //
//////////////////////////////////////////////////////////////////////
int priceRows = store.query(AssistantModelPrice.TABLE_NAME, new QQueryFilter().withLimit(LIMIT), AssistantModelPrice.class).size();
String rowsText = priceRows + " price row" + (priceRows == 1 ? "" : "s");
if(active == null)
{
items.add(new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.DEFAULT, rowsText + "; checked once a provider answers.", null));
}
else
{
AssistantProvider row = active.row();
ModelPricing price = runtime.getProviderService().pricing(new ProviderService.Active(row, row.getProviderType(), row.getModel(), null), row.getModel());
boolean local = "LOCAL".equals(active.location());
boolean known = price != null && (positive(price.getInputUsdPerMTok()) || positive(price.getOutputUsdPerMTok()));
items.add(known || local
? new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.OK, local && !known ? "The default model is local and costs nothing. " + rowsText + "."
: "The default model is priced at $" + price.getInputUsdPerMTok() + " in / $" + price.getOutputUsdPerMTok() + " out per million tokens. " + rowsText + ".", null)
: new Item("Model prices", AssistantModelPrice.TABLE_NAME, State.TODO, "The default model (" + row.getModel() + ") has no price, so its calls cost $0 and cost budgets never trigger.",
"Add a price row for its provider type and model."));
}
////////////
// policy //
@@ -204,4 +217,15 @@ public final class AssistantSetupStatus
return (rows.stream().filter(row -> !Boolean.FALSE.equals(row.toQRecord().getValueBoolean("enabled"))).toList());
}
/*******************************************************************************
**
*******************************************************************************/
private static boolean positive(Double value)
{
return (value != null && value > 0);
}
}
@@ -103,6 +103,11 @@ public final class DataAccessDefaultsProcess
*******************************************************************************/
public static List<AssistantDataAccess> missingRows(QInstance qInstance, DataAccessService access, AssistantStore store) throws QException
{
////////////////////////////////////////////////////////////////////
// fresh rows: a row deleted on another server must not come back //
// from this server's cache as a "default" //
////////////////////////////////////////////////////////////////////
access.invalidate();
Set<String> existing = new HashSet<>();
for(AssistantDataAccess row : store.query(AssistantDataAccess.TABLE_NAME, new QQueryFilter().withLimit(10_000), AssistantDataAccess.class))
{
@@ -26,8 +26,8 @@ WHERE NOT EXISTS (SELECT 1 FROM assistant_policy);
-- changeset qbit-assistant:defaults-2
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_profile WHERE name = 'default'
-- comment: The default profile (persona): used for every app and user that no more specific profile matches.
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_profile WHERE name = 'default' OR is_default = TRUE
-- comment: The default profile (persona): used for every app and user that no more specific profile matches. Skipped when the host already has a default profile, so there is never a second default.
INSERT INTO assistant_profile (name, display_name, icon, personality, greeting, starter_prompts, is_default, enabled, create_date, modify_date)
SELECT 'default', 'Assistant', 'auto_awesome',
'Be concise, friendly and plain-spoken. Lead with the answer, then the detail that supports it. When something is ambiguous, ask one short question instead of guessing. Offer a link to the list or record you used so the user can check it.',
@@ -36,7 +36,7 @@ SELECT 'default', 'Assistant', 'auto_awesome',
Which tables can I look at?
What changed in the last 7 days?',
TRUE, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM assistant_profile WHERE name = 'default');
WHERE NOT EXISTS (SELECT 1 FROM assistant_profile WHERE name = 'default' OR is_default = TRUE);
-- rollback DELETE FROM assistant_profile WHERE name = 'default';
-- changeset qbit-assistant:defaults-3
@@ -101,7 +101,7 @@ WHERE NOT EXISTS (SELECT 1 FROM assistant_skill WHERE name = 'access-questions')
-- changeset qbit-assistant:defaults-7
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-table-exists tableName:assistant_profile_skill
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_profile_skill WHERE 1 = 0
-- comment: Offer the starter skills through the default profile (skills reach the model only through a profile). Pairs that already exist are left alone.
INSERT INTO assistant_profile_skill (profile_id, skill_id, create_date, modify_date)
SELECT p.id, s.id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
@@ -113,19 +113,19 @@ WHERE p.name = 'default'
-- changeset qbit-assistant:defaults-8
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_budget WHERE name = 'Monthly spend (whole instance)'
-- comment: A monthly cost budget for the whole instance: warn at 80%, switch to the cheapest allowed model (local first) at 100%, stop at 120%. Change the limit to suit; overrides raise it for a while.
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_budget
-- comment: Only when the host has no budget at all, so an existing host's limits never change on deploy. A monthly cost budget for the whole instance: warn at 80%, switch to the cheapest allowed model (local first) at 100%, stop at 120%. Change the limit to suit; overrides raise it for a while.
INSERT INTO assistant_budget (name, enabled, scope_type, period, metric, limit_value, warn_percent, at_limit_action, hard_ceiling_percent, notes, create_date, modify_date)
SELECT 'Monthly spend (whole instance)', TRUE, 'INSTANCE', 'MONTH', 'COST_USD', 50, 80, 'DOWNGRADE', 120,
'Starter budget. Warns everyone at 80% of $50 a month, switches to the cheapest allowed model at 100% (or stops when there is none), and stops at 120%. Set Downgrade Model to choose the fallback; add an override to raise it temporarily.',
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM assistant_budget WHERE name = 'Monthly spend (whole instance)');
WHERE NOT EXISTS (SELECT 1 FROM assistant_budget);
-- rollback DELETE FROM assistant_budget WHERE name = 'Monthly spend (whole instance)';
-- changeset qbit-assistant:defaults-9
-- preconditions onFail:MARK_RAN onError:MARK_RAN
-- precondition-table-exists tableName:assistant_model_price
-- comment: Anthropic first-party list prices, USD per million tokens (2026-10). Rows that already exist (by type and model) are left alone. Empty cache prices use the standard multipliers (read 0.1x, 5 minute write 1.25x, 1 hour write 2x input).
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM assistant_model_price
-- comment: Anthropic first-party list prices, USD per million tokens (2026-10), only when the host has no price rows (rows take precedence over AssistantQBitConfig.withPricing, so a host's configured prices are never overridden on deploy). Empty cache prices use the standard multipliers (read 0.1x, 5 minute write 1.25x, 1 hour write 2x input).
INSERT INTO assistant_model_price (provider_type, model, input_usd_per_m_tok, output_usd_per_m_tok, cache_read_usd_per_m_tok, cache_write5m_usd_per_m_tok, cache_write1h_usd_per_m_tok, notes, create_date, modify_date)
SELECT v.provider_type, v.model, v.input_price, v.output_price, v.cache_read, v.cache_write5m, v.cache_write1h, v.notes, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM (VALUES
@@ -142,7 +142,7 @@ FROM (VALUES
('anthropic', 'claude-haiku-5-5', 0.10, 0.50, 0.01, 0.125, 0.20, 'Anthropic list price, 2026-10 (prompts up to 100K tokens)'),
('anthropic', 'claude-haiku-4-5', 1.00, 5.00, 0.10, 1.25, 2.00, 'Anthropic list price, 2026-10')
) AS v (provider_type, model, input_price, output_price, cache_read, cache_write5m, cache_write1h, notes)
WHERE NOT EXISTS (SELECT 1 FROM assistant_model_price p WHERE p.provider_type = v.provider_type AND p.model = v.model);
WHERE NOT EXISTS (SELECT 1 FROM assistant_model_price p);
-- rollback SELECT 1; -- prices may have been edited; keep them
-- changeset qbit-assistant:defaults-10
@@ -47,6 +47,12 @@ import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.testing.AssistantTestFixture;
import com.kingsrook.qbits.assistant.testing.BaseAssistantTest;
import com.kingsrook.qqq.backend.core.context.QContext;
import liquibase.Contexts;
import liquibase.Liquibase;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -82,6 +88,7 @@ class AssistantDefaultsChangelogTest extends BaseAssistantTest
try(Connection connection = freshSchema())
{
runChangelog(connection);
assertThat(ranChangesets(connection)).hasSize(10).allSatisfy((id, type) -> assertThat(type).as(id).isEqualTo("EXECUTED"));
Map<String, Integer> first = counts(connection);
assertThat(first).containsEntry("assistant_policy", 1).containsEntry("assistant_profile", 1).containsEntry("assistant_skill", 4)
.containsEntry("assistant_profile_skill", 4).containsEntry("assistant_budget", 1).containsEntry("assistant_scheduled_task", 1);
@@ -94,6 +101,7 @@ class AssistantDefaultsChangelogTest extends BaseAssistantTest
}
runChangelog(connection);
assertThat(ranChangesets(connection)).hasSize(10);
assertThat(counts(connection)).isEqualTo(first);
assertThat(scalar(connection, "SELECT limit_value FROM assistant_budget")).isEqualTo("75");
assertThat(scalar(connection, "SELECT display_name FROM assistant_profile")).isEqualTo("Ada");
@@ -104,17 +112,51 @@ class AssistantDefaultsChangelogTest extends BaseAssistantTest
/*******************************************************************************
** An existing policy row (the host's) is never joined by a second one.
** A host that already configured the assistant: its policy, default
** profile, budget and prices are left exactly as they were (no second
** default, no new limit, no price that overrides its configured pricing).
*******************************************************************************/
@Test
void keepsAnExistingPolicy() throws Exception
void neverChangesAnExistingHost() throws Exception
{
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
{
statement.execute("INSERT INTO assistant_policy (name, kill_switch) VALUES ('host', TRUE)");
statement.execute("INSERT INTO assistant_profile (name, display_name, is_default, enabled) VALUES ('host', 'Hal', TRUE, TRUE)");
statement.execute("INSERT INTO assistant_budget (name, scope_type, period, metric, limit_value) VALUES ('Host daily', 'USER', 'DAY', 'TOKENS', 1000)");
statement.execute("INSERT INTO assistant_model_price (provider_type, model, input_usd_per_m_tok, output_usd_per_m_tok) VALUES ('anthropic', 'claude-sonnet-5-5', 1.00, 5.00)");
runChangelog(connection);
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_policy")).isEqualTo("1");
assertThat(scalar(connection, "SELECT name FROM assistant_policy")).isEqualTo("host");
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_profile WHERE is_default = TRUE")).isEqualTo("1");
assertThat(scalar(connection, "SELECT name FROM assistant_profile")).isEqualTo("host");
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_budget")).isEqualTo("1");
assertThat(scalar(connection, "SELECT name FROM assistant_budget")).isEqualTo("Host daily");
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_model_price")).isEqualTo("1");
assertThat(scalar(connection, "SELECT input_usd_per_m_tok FROM assistant_model_price")).isEqualTo("1");
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_skill")).as("starter skills are still added").isEqualTo("4");
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_profile_skill")).as("the host's profile is not changed").isEqualTo("0");
assertThat(ranChangesets(connection)).containsEntry("defaults-1", "MARK_RAN").containsEntry("defaults-2", "MARK_RAN")
.containsEntry("defaults-8", "MARK_RAN").containsEntry("defaults-9", "MARK_RAN");
}
}
/*******************************************************************************
** A host without one of the tables: that changeset is marked ran, the
** rest apply, nothing fails.
*******************************************************************************/
@Test
void missingTableIsSkipped() throws Exception
{
try(Connection connection = freshSchema(); Statement statement = connection.createStatement())
{
statement.execute("DROP TABLE assistant_model_price");
runChangelog(connection);
assertThat(ranChangesets(connection)).containsEntry("defaults-9", "MARK_RAN").containsEntry("defaults-8", "EXECUTED").containsEntry("defaults-7", "EXECUTED");
assertThat(scalar(connection, "SELECT COUNT(*) FROM assistant_profile_skill")).isEqualTo("4");
}
}
@@ -131,7 +173,8 @@ class AssistantDefaultsChangelogTest extends BaseAssistantTest
assertThat(changesets).hasSizeGreaterThanOrEqualTo(SEEDED_TABLES.size());
for(String changeset : changesets)
{
assertThat(changeset).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("-- rollback ").contains("NOT EXISTS");
assertThat(changeset).contains("-- preconditions onFail:MARK_RAN onError:MARK_RAN").contains("-- precondition-sql-check expectedResult:0 ")
.doesNotContain("precondition-table-exists").contains("-- rollback ").contains("NOT EXISTS");
}
for(String table : SEEDED_TABLES)
{
@@ -269,7 +312,7 @@ class AssistantDefaultsChangelogTest extends BaseAssistantTest
*******************************************************************************/
static Connection freshSchema() throws Exception
{
Connection connection = DriverManager.getConnection("jdbc:h2:mem:defaults" + System.nanoTime() + ";MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH");
Connection connection = DriverManager.getConnection("jdbc:h2:mem:defaults" + System.nanoTime() + ";MODE=PostgreSQL;DEFAULT_NULL_ORDERING=HIGH");
String ddl = Files.readString(Path.of("..", "docs", "schema-postgres.sql"), StandardCharsets.UTF_8);
try(Statement statement = connection.createStatement())
{
@@ -288,21 +331,30 @@ class AssistantDefaultsChangelogTest extends BaseAssistantTest
/*******************************************************************************
** Run every changeset's statements (comments, preconditions and rollbacks
** are comment lines, as Liquibase formatted SQL).
** Run the changelog through Liquibase, as a host does.
*******************************************************************************/
static void runChangelog(Connection connection) throws Exception
{
try(Statement statement = connection.createStatement())
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
new Liquibase(CHANGELOG.substring(1), new ClassLoaderResourceAccessor(), database).update(new Contexts());
}
/*******************************************************************************
** changeset id -> EXECTYPE (EXECUTED, MARK_RAN, ...).
*******************************************************************************/
static Map<String, String> ranChangesets(Connection connection) throws Exception
{
Map<String, String> rs = new HashMap<>();
try(Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery("SELECT id, exectype FROM databasechangelog"))
{
for(String changeset : changesets(changelogText()))
while(result.next())
{
for(String sql : statements(changeset))
{
statement.execute(sql);
}
rs.put(result.getString(1), result.getString(2));
}
}
return (rs);
}
@@ -24,8 +24,10 @@ import java.util.stream.Collectors;
import com.kingsrook.qbits.assistant.access.DataAccessMode;
import com.kingsrook.qbits.assistant.model.AssistantBudget;
import com.kingsrook.qbits.assistant.model.AssistantDataAccess;
import com.kingsrook.qbits.assistant.model.AssistantModelPrice;
import com.kingsrook.qbits.assistant.model.AssistantProfile;
import com.kingsrook.qbits.assistant.model.AssistantProfileSkill;
import com.kingsrook.qbits.assistant.model.AssistantProvider;
import com.kingsrook.qbits.assistant.model.AssistantSkill;
import com.kingsrook.qbits.assistant.persistence.AssistantStore;
import com.kingsrook.qbits.assistant.processes.DataAccessDefaultsProcess;
@@ -55,7 +57,7 @@ class AssistantSetupTest extends BaseAssistantTest
{
Map<String, AssistantSetupStatus.Item> items = items();
assertThat(items.keySet()).containsExactly("Provider", "Model prices", "Policy", "Profiles", "Skills", "Budgets", "Data access", "Knowledge sources", "Scheduled tasks");
assertThat(items.get("Model prices").state()).isEqualTo(AssistantSetupStatus.State.TODO);
assertThat(items.get("Model prices").state()).as("no provider yet").isEqualTo(AssistantSetupStatus.State.DEFAULT);
assertThat(items.get("Budgets").state()).isEqualTo(AssistantSetupStatus.State.TODO);
assertThat(items.get("Budgets").nextStep()).contains("monthly instance budget");
assertThat(items.get("Profiles").state()).isEqualTo(AssistantSetupStatus.State.DEFAULT);
@@ -81,6 +83,41 @@ class AssistantSetupTest extends BaseAssistantTest
/*******************************************************************************
** Prices resolve as calls are costed: a model priced only by the code
** configuration is set up; an unpriced cloud model needs a price row; a
** local model costs nothing.
*******************************************************************************/
@Test
void priceCheckUsesTheRuntimePricing() throws Exception
{
AssistantStore store = adminStore();
assertThat(priceItem("anthropic", "claude-sonnet-5-5", "CLOUD").state()).as("config-priced").isEqualTo(AssistantSetupStatus.State.OK);
AssistantSetupStatus.Item unpriced = priceItem("anthropic", "mystery-model-1", "CLOUD");
assertThat(unpriced.state()).isEqualTo(AssistantSetupStatus.State.TODO);
assertThat(unpriced.detail()).contains("mystery-model-1");
assertThat(priceItem("openai-compatible", "qwen3:30b-a3b", "LOCAL").state()).as("local").isEqualTo(AssistantSetupStatus.State.OK);
store.insert(new AssistantModelPrice().withProviderType("anthropic").withModel("mystery-model-1").withInputUsdPerMTok(new BigDecimal("1.5"))
.withOutputUsdPerMTok(new BigDecimal("7")));
config.getRuntime().getProviderService().invalidate();
assertThat(priceItem("anthropic", "mystery-model-1", "CLOUD").state()).as("price row added").isEqualTo(AssistantSetupStatus.State.OK);
}
/*******************************************************************************
** The Model prices item for an active provider of this type and model.
*******************************************************************************/
private AssistantSetupStatus.Item priceItem(String type, String model, String location) throws Exception
{
AssistantProvider row = new AssistantProvider().withId(90_000 + (int) (System.nanoTime() % 1000)).withName(type + " " + model).withProviderType(type).withModel(model)
.withLocation(location).withEnabled(true);
AssistantHomeData.ProviderHealth health = new AssistantHomeData.ProviderHealth(row, type, "Set", true, List.of(), true, 0L, null, null, location, 0L);
return (AssistantSetupStatus.load(config.getRuntime(), List.of(health)).stream().filter(i -> i.label().equals("Model prices")).findFirst().orElseThrow());
}
/*******************************************************************************
** The action writes the defaults out as rows (READ, or NONE for sensitive
** tables), keeps existing rows, adds nothing the second time, and the