Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@
&& margin <= 1.0
&& (int) (maxInputTokens * margin) - outputBuffer <= 0) {
problems.add(
"the effective output buffer ("

Check failure on line 249 in src/main/java/dev/thiagogonzaga/thrillhousebot/config/StartupConfigValidator.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "the effective output buffer (" 3 times.

See more on https://sonarcloud.io/project/issues?id=devops-thiago_ThrillhouseBot&issues=AZ_uGb6-BTR_KTfKyPZT&open=AZ_uGb6-BTR_KTfKyPZT&pullRequest=520
+ outputBuffer
+ ") must be less than the effective max input tokens x safety margin ("
+ (int) (maxInputTokens * margin)
Expand Down Expand Up @@ -342,6 +342,14 @@
* and reply calls β€” #498) at boot, the same fail-fast contract as the other budget keys. Empty is
* allowed: an operator who clears {@code REVIEW_CONCISE_MAX_OUTPUT_TOKENS} drops the cap and the
* provider default applies.
*
* <p>On a shared-window active model the concise cap is also held to the same reservation rule as
* the active model's own response cap ({@link #validateEffectiveBudget}): the concise calls spend
* their {@code max_tokens} out of the same window the budgeter packed the prompt into with only
* {@code reservedOutputTokens} held back, so licensing more output than was reserved overruns the
* window just as surely from this knob (#517). Not applied when token budgeting is off (no packed
* prompt to overrun) or when the active model declares {@code separate-output-budget} (nothing is
* reserved, and the response never draws on the window).
*/
private void validateConciseResponseCap(List<String> problems) {
conciseMaxOutputTokens
Expand All @@ -352,6 +360,29 @@
"REVIEW_CONCISE_MAX_OUTPUT_TOKENS must be >= 1"
+ " (quarkus.langchain4j.openai.concise.chat-model.max-tokens): "
+ v));
if (activeModel.maxInputTokens() > 0 && !activeModel.separateOutputBudget()) {
var outputBuffer = activeModel.reservedOutputTokens();
conciseMaxOutputTokens
.filter(v -> v > outputBuffer)
.ifPresent(
v ->
problems.add(
"the effective output buffer ("
+ outputBuffer
+ ") must be >= REVIEW_CONCISE_MAX_OUTPUT_TOKENS ("
+ v
+ ", quarkus.langchain4j.openai.concise.chat-model.max-tokens) for model"
+ " '"
+ activeModel.modelName()
+ "' so the token budget reserves the response cap the"
+ " summary/verifier/reply calls send. Lower"
+ " REVIEW_CONCISE_MAX_OUTPUT_TOKENS, raise"
+ " REVIEW_OUTPUT_BUFFER_TOKENS to cover it, or set"
+ " thrillhousebot.ai.models.\""
+ activeModel.modelName()
+ "\".separate-output-budget=true if this model's response allowance is"
+ " independent of its input window."));
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,19 @@
*/
package dev.thiagogonzaga.thrillhousebot.config;

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;

import io.smallrye.config.ConfigMapping;
import io.smallrye.config.PropertiesConfigSource;
import io.smallrye.config.SmallRyeConfigBuilder;
import io.smallrye.config.WithName;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -163,6 +170,11 @@ ConfigBuilder conciseMaxOutputTokens(Optional<Integer> v) {
return this;
}

ConfigBuilder modelName(String v) {
this.modelName = v;
return this;
}

ConfigBuilder model(String name, ThrillhouseConfig.AiPricingConfig.ModelSettings settings) {
this.models.put(name, settings);
return this;
Expand Down Expand Up @@ -334,6 +346,70 @@ void bootsWhenTheConciseResponseCapIsUnset() {
new ConfigBuilder().conciseMaxOutputTokens(Optional.empty()).build().validate();
}

@Test
void failsFastWhenTheConciseCapExceedsTheBufferOnASharedWindow() {
// #517: post-#507 the summary/verifier/reply calls send REVIEW_CONCISE_MAX_OUTPUT_TOKENS as
// max_tokens against the same shared window the budgeter packed to budget - buffer, so the
// concise cap is held to the same reservation rule as the active model's own response cap:
// 384000 on the active cap refuses boot, and the identical value on the concise cap must too.
var ex =
assertFailsValidation(
new ConfigBuilder().conciseMaxOutputTokens(Optional.of(384_000)).build());
assertTrue(
ex.getMessage()
.contains(
"effective output buffer (8192) must be >= REVIEW_CONCISE_MAX_OUTPUT_TOKENS"
+ " (384000"),
ex.getMessage());
// The message has to hand the operator every way out, in the active-model rule's style.
assertTrue(ex.getMessage().contains("REVIEW_OUTPUT_BUFFER_TOKENS"), ex.getMessage());
assertTrue(
ex.getMessage()
.contains("thrillhousebot.ai.models.\"deepseek-chat\".separate-output-budget=true"),
"the failure must point at the escape hatch: " + ex.getMessage());
}

@Test
void allowsAConciseCapAboveTheBufferWhenTheOutputBudgetIsSeparate() {
// On a separate-output-budget model nothing is reserved out of the window for responses, so
// the shared-window rule does not apply to the concise cap either β€” the deepseek-v4-flash
// shape (384000 out against an 8192 buffer) must keep booting.
var settings = emptyModelSettings();
lenient().when(settings.maxInputTokens()).thenReturn(Optional.of(1_000_000));
lenient().when(settings.separateOutputBudget()).thenReturn(Optional.of(true));

new ConfigBuilder()
.model("deepseek-chat", settings)
.conciseMaxOutputTokens(Optional.of(384_000))
.build()
.validate();
}

@Test
void allowsAConciseCapAboveTheBufferWhenTokenBudgetingIsDisabled() {
// With budgeting off there is no packed prompt to overrun, so β€” exactly like the active-model
// rule β€” the concise reservation check does not apply.
new ConfigBuilder()
.maxInputTokens(0)
.conciseMaxOutputTokens(Optional.of(384_000))
.build()
.validate();
}

@Test
void holdsTheConciseCapToTheActiveModelsEffectiveBuffer() {
// The reservation the rule compares against is the active model's resolved buffer β€” a
// per-model output-buffer override that covers the concise cap must boot.
var settings = emptyModelSettings();
lenient().when(settings.outputBufferTokens()).thenReturn(Optional.of(16_384));

new ConfigBuilder()
.model("deepseek-chat", settings)
.conciseMaxOutputTokens(Optional.of(16_384))
.build()
.validate();
}

@Test
void failsFastWhenSafetyMarginOutOfRange() {
assertTrue(
Expand Down Expand Up @@ -515,7 +591,13 @@ void bootsWhenAModelSettingsEntryIsValid() {
lenient().when(settings.frequencyPenalty()).thenReturn(Optional.of(-2.0));
lenient().when(settings.presencePenalty()).thenReturn(Optional.of(2.0));
lenient().when(settings.seed()).thenReturn(Optional.of(42));
new ConfigBuilder().model("deepseek-chat", settings).build().validate();
// The 4096 buffer override shrinks the shared-window reservation, so the concise cap must fit
// inside it too (#517) β€” this test's subject is the per-model entry, not that rule.
new ConfigBuilder()
.conciseMaxOutputTokens(Optional.of(4_096))
.model("deepseek-chat", settings)
.build()
.validate();
}

@Test
Expand All @@ -534,8 +616,11 @@ void bootsWhenAPerModelOverrideRepairsABrokenGlobalCombination() {
// override restores headroom, so the boot must succeed.
var settings = emptyModelSettings();
lenient().when(settings.outputBufferTokens()).thenReturn(Optional.of(1_000));
// The 1000 buffer override also shrinks the shared-window reservation below the default
// concise cap, so the cap is lowered with it (#517) β€” this test pins the repair, not that rule.
new ConfigBuilder()
.outputBufferTokens(45_000)
.conciseMaxOutputTokens(Optional.of(1_000))
.model("deepseek-chat", settings)
.build()
.validate();
Expand Down Expand Up @@ -673,6 +758,83 @@ void classifiesDashboardOauthStatusForEveryCombination() {
StartupConfigValidator.dashboardOauthStatus(false, true));
}

@Nested
class ShippedDefaults {

/**
* Budget-relevant subset of the shipped per-model table, bound straight from {@code
* src/main/resources/application.properties} (no env source, so every {@code ${VAR:default}}
* resolves to its shipped default).
*/
@ConfigMapping(prefix = "thrillhousebot.ai")
interface ShippedModelsProbe {
Map<String, ModelProbe> models();

interface ModelProbe {
@WithName("max-input-tokens")
Optional<Integer> maxInputTokens();

@WithName("max-output-tokens")
Optional<Integer> maxOutputTokens();

@WithName("output-buffer-tokens")
Optional<Integer> outputBufferTokens();

@WithName("separate-output-budget")
Optional<Boolean> separateOutputBudget();
}
}

@Test
void everyShippedModelBootsUnderTheShippedConciseCap() throws Exception {
// The #502 lesson: the concise reservation rule only fires for the ACTIVE model, so a bad
// shipped combination (a concise default above some model's effective buffer) would pass
// every fixed-model test and refuse boot only for deployments naming that model. Walk the
// whole shipped table with each entry active instead of trusting the defaults.
var shipped =
new SmallRyeConfigBuilder()
.addDefaultInterceptors() // ${VAR:default} expansion, as at runtime
.withValidateUnknown(false)
.withMapping(ShippedModelsProbe.class)
.withSources(
new PropertiesConfigSource(
Paths.get("src/main/resources/application.properties").toUri().toURL()))
.build();
var conciseCap =
shipped.getValue(
"quarkus.langchain4j.openai.concise.chat-model.max-tokens", Integer.class);
var reviewBuffer = shipped.getValue("thrillhousebot.review.output-buffer-tokens", int.class);
var reviewBudget = shipped.getValue("thrillhousebot.review.max-input-tokens", int.class);
var models = shipped.getConfigMapping(ShippedModelsProbe.class).models();
assertFalse(models.isEmpty(), "the shipped model table must resolve");

models.forEach(
(name, probe) -> {
var settings = emptyModelSettings();
lenient().when(settings.maxInputTokens()).thenReturn(probe.maxInputTokens());
lenient().when(settings.maxOutputTokens()).thenReturn(probe.maxOutputTokens());
lenient().when(settings.outputBufferTokens()).thenReturn(probe.outputBufferTokens());
lenient()
.when(settings.separateOutputBudget())
.thenReturn(probe.separateOutputBudget());
var validator =
new ConfigBuilder()
.modelName(name)
.maxInputTokens(reviewBudget)
.outputBufferTokens(reviewBuffer)
.conciseMaxOutputTokens(Optional.of(conciseCap))
.model(name, settings)
.build();
assertDoesNotThrow(
validator::validate,
"shipped defaults must boot with model '"
+ name
+ "' active under the shipped concise cap "
+ conciseCap);
});
}
}

@Nested
class ModelEnvVarMapping {

Expand Down
Loading