diff --git a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/FindAndRerankCommand.java b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/FindAndRerankCommand.java index 079ee9ae84..c68af8a475 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/FindAndRerankCommand.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/api/model/command/impl/FindAndRerankCommand.java @@ -103,7 +103,17 @@ public record Options( @Schema( description = "Return vector embedding used for ANN sorting.", type = SchemaType.BOOLEAN) - boolean includeSortVector) {} + boolean includeSortVector, + @Nullable + @Valid + @Schema( + description = + "Optional per-request override for the reranking service. Completely replaces the" + + " collection-level reranking configuration when provided. Both provider and" + + " modelName are required.", + implementation = CreateCollectionCommand.Options.RerankServiceDesc.class) + @JsonProperty("rerank") + CreateCollectionCommand.Options.RerankServiceDesc rerankServiceOverride) {} @JsonDeserialize(using = HybridLimitsDeserializer.class) public record HybridLimits( diff --git a/src/main/java/io/stargate/sgv2/jsonapi/exception/RequestException.java b/src/main/java/io/stargate/sgv2/jsonapi/exception/RequestException.java index 9abb74b74a..d5b8fc9266 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/exception/RequestException.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/exception/RequestException.java @@ -54,6 +54,7 @@ public enum Code implements ErrorCode { HYBRID_FIELD_UNSUPPORTED_SUBFIELD_VALUE_TYPE, INVALID_CREATE_COLLECTION_FIELD, + INVALID_RERANK_OVERRIDE, MISSING_RERANK_QUERY_TEXT, REQUEST_NOT_JSON, diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilder.java b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilder.java index 3fe5a92921..7ed5393102 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilder.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilder.java @@ -24,7 +24,9 @@ import io.stargate.sgv2.jsonapi.service.operation.reranking.*; import io.stargate.sgv2.jsonapi.service.operation.tasks.*; import io.stargate.sgv2.jsonapi.service.provider.ApiModelSupport; +import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfig; import io.stargate.sgv2.jsonapi.service.reranking.operation.RerankingProvider; +import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionRerankDef; import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionSchemaObject; import io.stargate.sgv2.jsonapi.service.shredding.Deferrable; import io.stargate.sgv2.jsonapi.service.shredding.DeferredAction; @@ -56,6 +58,9 @@ class FindAndRerankOperationBuilder { private FindAndRerankCommand command; private FindCommandResolver findCommandResolver; + // lazily computed effective rerank service def (per-request override or collection default) + private CollectionRerankDef.RerankServiceDef effectiveRerankServiceDef; + public FindAndRerankOperationBuilder(CommandContext commandContext) { this.commandContext = Objects.requireNonNull(commandContext, "commandContext cannot be null"); @@ -160,45 +165,63 @@ private void checkSupported() { } } - if (!commandContext.schemaObject().rerankingConfig().enabled()) { - // TODO: more info in the error + var rerankOverride = + getOrDefault(command.options(), FindAndRerankCommand.Options::rerankServiceOverride, null); + boolean hasOverride = rerankOverride != null && !rerankOverride.isEmpty(); + + if (!commandContext.schemaObject().rerankingConfig().enabled() && !hasOverride) { throw RequestException.Code.UNSUPPORTED_RERANKING_COMMAND.get(); } - // Read is not supported for rerank model at END_OF_LIFE support status. - var rerankingProvidersConfig = commandContext.rerankingProviderFactory().getRerankingConfig(); - var modelConfig = - rerankingProvidersConfig.filterByRerankServiceDef( - commandContext.schemaObject().rerankingConfig().rerankServiceDef()); - // Validate if the model is END_OF_LIFE - if (modelConfig.apiModelSupport().status() == ApiModelSupport.SupportStatus.END_OF_LIFE) { - throw SchemaException.Code.END_OF_LIFE_AI_MODEL.get( - Map.of( - "model", - modelConfig.name(), - "modelStatus", - modelConfig.apiModelSupport().status().name(), - "message", - modelConfig - .apiModelSupport() - .message() - .orElse("The model is no longer supported (reached its end-of-life)."))); + + // Resolve effective rerank service def: per-request override replaces collection config + // entirely; otherwise fall back to the collection's configured defaults. + if (hasOverride) { + var rerankingProvidersConfig = commandContext.rerankingProviderFactory().getRerankingConfig(); + validateRerankOverride( + rerankingProvidersConfig, rerankOverride.provider(), rerankOverride.modelName()); + effectiveRerankServiceDef = + new CollectionRerankDef.RerankServiceDef( + rerankOverride.provider(), + rerankOverride.modelName(), + rerankOverride.authentication(), + rerankOverride.parameters()); + } else { + // Collection defaults: check END_OF_LIFE since model may have become EOL after creation. + // (validateRerankOverride already covers DEPRECATED+EOL for overrides above.) + effectiveRerankServiceDef = + commandContext.schemaObject().rerankingConfig().rerankServiceDef(); + var rerankingProvidersConfig = commandContext.rerankingProviderFactory().getRerankingConfig(); + var modelConfig = + rerankingProvidersConfig.filterByRerankServiceDef(effectiveRerankServiceDef); + if (modelConfig.apiModelSupport().status() == ApiModelSupport.SupportStatus.END_OF_LIFE) { + throw SchemaException.Code.END_OF_LIFE_AI_MODEL.get( + Map.of( + "model", + modelConfig.name(), + "modelStatus", + modelConfig.apiModelSupport().status().name(), + "message", + modelConfig + .apiModelSupport() + .message() + .orElse("The model is no longer supported (reached its end-of-life)."))); + } } } private TaskGroupAndDeferrables, CollectionSchemaObject> rerankTasks(List deferredCommandResults) { - // Previous code will check reranking is supported - var providerConfig = commandContext.schemaObject().rerankingConfig().rerankServiceDef(); + // checkSupported() already resolved effectiveRerankServiceDef RerankingProvider rerankingProvider = commandContext .rerankingProviderFactory() .create( commandContext.requestContext().tenant(), commandContext.requestContext().authToken(), - providerConfig.provider(), - providerConfig.modelName(), - providerConfig.authentication(), + effectiveRerankServiceDef.provider(), + effectiveRerankServiceDef.modelName(), + effectiveRerankServiceDef.authentication(), commandContext.commandName()); // todo: move to a builder pattern, mosty to make it easier to manage the task position and @@ -238,6 +261,63 @@ private void checkSupported() { .collect(java.util.stream.Collectors.toUnmodifiableList())); } + /** + * Validates that the overridden provider and model exist and are usable in the reranking + * providers configuration. + */ + // package-private for unit testing + void validateRerankOverride( + RerankingProvidersConfig rerankingProvidersConfig, String provider, String modelName) { + var providerConfig = rerankingProvidersConfig.providers().get(provider); + if (providerConfig == null) { + throw RequestException.Code.INVALID_RERANK_OVERRIDE.get( + "message", "Reranking provider '%s' is not supported.".formatted(provider)); + } + if (!providerConfig.enabled()) { + throw RequestException.Code.INVALID_RERANK_OVERRIDE.get( + "message", "Reranking provider '%s' is disabled.".formatted(provider)); + } + // provider is guaranteed non-null by @NotNull on RerankServiceDesc.provider; + // modelName has no @NotNull so we must check explicitly + if (modelName == null) { + throw RequestException.Code.INVALID_RERANK_OVERRIDE.get( + "message", + "The 'modelName' field is required when specifying a reranking service override."); + } + var modelConfig = + providerConfig.models().stream() + .filter(m -> m.name().equals(modelName)) + .findFirst() + .orElse(null); + if (modelConfig == null) { + throw RequestException.Code.INVALID_RERANK_OVERRIDE.get( + "message", + "Model '%s' is not supported by reranking provider '%s'.".formatted(modelName, provider)); + } + + // Block DEPRECATED and END_OF_LIFE models for per-request overrides (user is actively + // choosing this model, so both statuses should be rejected) + if (modelConfig.apiModelSupport().status() != ApiModelSupport.SupportStatus.SUPPORTED) { + var errorCode = + modelConfig.apiModelSupport().status() == ApiModelSupport.SupportStatus.DEPRECATED + ? SchemaException.Code.DEPRECATED_AI_MODEL + : SchemaException.Code.END_OF_LIFE_AI_MODEL; + throw errorCode.get( + Map.of( + "model", + modelConfig.name(), + "modelStatus", + modelConfig.apiModelSupport().status().name(), + "message", + modelConfig + .apiModelSupport() + .message() + .orElse( + "The model is %s." + .formatted(modelConfig.apiModelSupport().status().name())))); + } + } + private TaskGroupAndDeferrables readTasks( RerankingTask.DeferredCommandWithSource deferredVectorRead, RerankingTask.DeferredCommandWithSource deferredBM25Read) { diff --git a/src/main/resources/errors.yaml b/src/main/resources/errors.yaml index 6d2df41abe..500bf50cfb 100644 --- a/src/main/resources/errors.yaml +++ b/src/main/resources/errors.yaml @@ -265,11 +265,21 @@ request-errors: code: UNSUPPORTED_RERANKING_COMMAND title: Reranking not enabled for collection body: |- - The `findAndRerank` is not enabled for the collection. - - The Command is only supported for collections that have `vector`, `lexical`, and `rerank` configured. They additionally require a vectorize configuration if `$vectorize` is used. - - Resend using a supported collection. + The `findAndRerank` is not enabled for the collection and no per-request reranking service override was provided. + + The Command is only supported for collections that have `vector`, `lexical`, and `rerank` configured, or when a per-request reranking service override is provided via the `rerank` option. They additionally require a vectorize configuration if `$vectorize` is used. + + Resend using a supported collection or provide a reranking service override in the command options. + + - scope: + code: INVALID_RERANK_OVERRIDE + title: Invalid reranking service override + body: |- + The per-request reranking service override in the findAndRerank command is invalid. + + ${message} + + Resend the command with a valid reranking service override, or omit the override to use the collection's default reranking configuration. # unscoped because this touches both the sort and the options - scope: diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankCollectionIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankCollectionIntegrationTest.java index 49caa1eeee..23df15d234 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankCollectionIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/FindAndRerankCollectionIntegrationTest.java @@ -6,7 +6,9 @@ import io.quarkus.test.common.WithTestResource; import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.stargate.sgv2.jsonapi.exception.EmbeddingProviderException; import io.stargate.sgv2.jsonapi.exception.RequestException; +import io.stargate.sgv2.jsonapi.exception.SchemaException; import io.stargate.sgv2.jsonapi.testresource.DseTestResource; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assumptions; @@ -22,7 +24,7 @@ @WithTestResource(value = DseTestResource.class) public class FindAndRerankCollectionIntegrationTest extends AbstractCollectionIntegrationTestBase { - // used to cleanup the collection from a previous test, if non-null + // used to clean up the collection from a previous test, if non-null private String cleanupCollectionName = null; @BeforeAll @@ -166,6 +168,173 @@ private void errorOnNotEnabled( containsString(errorMessageContains.formatted(keyspaceName, collectionName))); } + // ---- Per-request reranking override tests ---- + // These use a collection with vectorize enabled but NO reranking configured. + // Sort uses $hybrid with only $vectorize (no $lexical) to avoid needing lexical support. + + private static final String VECTORIZE_NO_RERANK_SPEC = + """ + { + "name" : "%s", + "options": { + "vector": { + "metric": "cosine", + "dimension": 1024, + "service": { + "provider": "openai", + "modelName": "text-embedding-3-small" + } + }, + "rerank": { + "enabled": false + } + } + } + """; + + @Test + void failOnRerankingDisabledNoOverride() { + String collectionName = "rerank_disabled_no_override"; + createCollectionWithCleanup(collectionName, VECTORIZE_NO_RERANK_SPEC); + + givenHeadersPostJsonThen(keyspaceName, collectionName, findAndRerankWithOverride(null)) + .body("$", responseIsError()) + .body("errors[0].errorCode", is(RequestException.Code.UNSUPPORTED_RERANKING_COMMAND.name())) + .body("errors[0].message", containsString("per-request reranking service override")); + } + + @Test + void failOnRerankOverrideUnknownProvider() { + String collectionName = "rerank_override_bad_provider"; + createCollectionWithCleanup(collectionName, VECTORIZE_NO_RERANK_SPEC); + + givenHeadersPostJsonThen( + keyspaceName, + collectionName, + findAndRerankWithOverride( + """ + {"provider": "unknown-provider", "modelName": "some-model"} + """)) + .body("$", responseIsError()) + .body("errors[0].errorCode", is(RequestException.Code.INVALID_RERANK_OVERRIDE.name())) + .body("errors[0].message", containsString("unknown-provider")); + } + + @Test + void failOnRerankOverrideMissingModelName() { + String collectionName = "rerank_override_no_model"; + createCollectionWithCleanup(collectionName, VECTORIZE_NO_RERANK_SPEC); + + givenHeadersPostJsonThen( + keyspaceName, + collectionName, + findAndRerankWithOverride( + """ + {"provider": "nvidia"} + """)) + .body("$", responseIsError()) + .body("errors[0].errorCode", is(RequestException.Code.INVALID_RERANK_OVERRIDE.name())) + .body("errors[0].message", containsString("modelName")); + } + + @Test + void failOnRerankOverrideDeprecatedModel() { + String collectionName = "rerank_override_deprecated"; + createCollectionWithCleanup(collectionName, VECTORIZE_NO_RERANK_SPEC); + + givenHeadersPostJsonThen( + keyspaceName, + collectionName, + findAndRerankWithOverride( + """ + {"provider": "nvidia", "modelName": "nvidia/a-random-deprecated-model"} + """)) + .body("$", responseIsError()) + .body("errors[0].errorCode", is(SchemaException.Code.DEPRECATED_AI_MODEL.name())); + } + + @Test + void failOnRerankOverrideEolModel() { + String collectionName = "rerank_override_eol"; + createCollectionWithCleanup(collectionName, VECTORIZE_NO_RERANK_SPEC); + + givenHeadersPostJsonThen( + keyspaceName, + collectionName, + findAndRerankWithOverride( + """ + {"provider": "nvidia", "modelName": "nvidia/a-random-EOL-model"} + """)) + .body("$", responseIsError()) + .body("errors[0].errorCode", is(SchemaException.Code.END_OF_LIFE_AI_MODEL.name())); + } + + @Test + void overrideWithAuthPassesValidation() { + overridePassesValidation( + "rerank_override_with_auth", + """ + {"provider": "nvidia", "modelName": "nvidia/llama-3.2-nv-rerankqa-1b-v2", + "authentication": {"providerKey": "my-test-key"}} + """); + } + + @Test + void overrideWithAuthAndParametersPassesValidation() { + overridePassesValidation( + "rerank_override_auth_params", + """ + {"provider": "nvidia", "modelName": "nvidia/llama-3.2-nv-rerankqa-1b-v2", + "authentication": {"providerKey": "my-test-key"}, + "parameters": {"truncate": "END"}} + """); + } + + @Test + void overrideWithParametersOnlyPassesValidation() { + overridePassesValidation( + "rerank_override_params_only", + """ + {"provider": "nvidia", "modelName": "nvidia/llama-3.2-nv-rerankqa-1b-v2", + "parameters": {"truncate": "END"}} + """); + } + + /** + * Verifies that a valid rerank override passes validation and the pipeline proceeds to the + * embedding (vectorize) step, which fails with EMBEDDING_PROVIDER_CLIENT_ERROR because there is + * no real OpenAI API key in the test environment. + */ + private void overridePassesValidation(String collectionName, String rerankOverrideJson) { + createCollectionWithCleanup(collectionName, VECTORIZE_NO_RERANK_SPEC); + + givenHeadersPostJsonThen( + keyspaceName, collectionName, findAndRerankWithOverride(rerankOverrideJson)) + .body("$", responseIsError()) + .body( + "errors[0].errorCode", + is(EmbeddingProviderException.Code.EMBEDDING_PROVIDER_CLIENT_ERROR.name())); + } + + /** + * Builds a findAndRerank command JSON with an optional rerank override in options. + * + * @param rerankOverrideJson JSON object for the "rerank" option, or null for no override. + */ + private static String findAndRerankWithOverride(String rerankOverrideJson) { + String rerankOption = rerankOverrideJson != null ? ", \"rerank\": " + rerankOverrideJson : ""; + return + """ + {"findAndRerank": { + "sort": {"$hybrid": {"$vectorize": "search text"}}, + "options": { + "limit": 10%s + } + }} + """ + .formatted(rerankOption); + } + private void createCollectionWithCleanup(String collectionName, String collectionSpec) { createComplexCollection(collectionSpec.formatted(collectionName)); // save the collection name for cleanup, but only after successful creation diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilderTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilderTest.java new file mode 100644 index 0000000000..0a128d1574 --- /dev/null +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/resolver/FindAndRerankOperationBuilderTest.java @@ -0,0 +1,186 @@ +package io.stargate.sgv2.jsonapi.service.resolver; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.stargate.sgv2.jsonapi.TestConstants; +import io.stargate.sgv2.jsonapi.api.model.command.CommandContext; +import io.stargate.sgv2.jsonapi.api.request.RequestContext; +import io.stargate.sgv2.jsonapi.exception.RequestException; +import io.stargate.sgv2.jsonapi.exception.SchemaException; +import io.stargate.sgv2.jsonapi.service.provider.ApiModelSupport; +import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfig; +import io.stargate.sgv2.jsonapi.service.reranking.configuration.RerankingProvidersConfigImpl; +import io.stargate.sgv2.jsonapi.service.schema.collections.CollectionSchemaObject; +import io.stargate.sgv2.jsonapi.testresource.NoGlobalResourcesTestProfile; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@QuarkusTest +@TestProfile(NoGlobalResourcesTestProfile.Impl.class) +class FindAndRerankOperationBuilderTest { + + @InjectMock protected RequestContext dataApiRequestInfo; + + private final TestConstants testConstants = new TestConstants(); + + private FindAndRerankOperationBuilder builder; + + // Reusable request properties for model configs + private static final RerankingProvidersConfigImpl.RerankingProviderConfigImpl.ModelConfigImpl + .RequestPropertiesImpl + REQUEST_PROPERTIES = + new RerankingProvidersConfigImpl.RerankingProviderConfigImpl.ModelConfigImpl + .RequestPropertiesImpl(3, 10, 100, 100, 0.5, 10); + + @BeforeEach + void beforeEach() { + CommandContext commandContext = testConstants.collectionContext(); + builder = new FindAndRerankOperationBuilder(commandContext); + } + + private static RerankingProvidersConfig.RerankingProviderConfig.ModelConfig modelConfig( + String name, ApiModelSupport.SupportStatus status) { + return new RerankingProvidersConfigImpl.RerankingProviderConfigImpl.ModelConfigImpl( + name, + new ApiModelSupport.ApiModelSupportImpl(status, Optional.empty()), + false, + "https://example.com/rerank", + REQUEST_PROPERTIES); + } + + private static RerankingProvidersConfig configWithProvider( + String providerName, + boolean enabled, + List models) { + return new RerankingProvidersConfigImpl( + Map.of( + providerName, + new RerankingProvidersConfigImpl.RerankingProviderConfigImpl( + false, providerName, enabled, Map.of(), models))); + } + + @Nested + class ValidateRerankOverride { + + // Shared config: nvidia provider enabled with a single supported model + private final RerankingProvidersConfig NVIDIA_SUPPORTED = + configWithProvider( + "nvidia", + true, + List.of(modelConfig("nvidia/rerank-v1", ApiModelSupport.SupportStatus.SUPPORTED))); + + @Test + void shouldAcceptSupportedProviderAndModel() { + assertThatCode( + () -> builder.validateRerankOverride(NVIDIA_SUPPORTED, "nvidia", "nvidia/rerank-v1")) + .doesNotThrowAnyException(); + } + + @Test + void shouldRejectUnknownProvider() { + assertThatThrownBy( + () -> + builder.validateRerankOverride( + NVIDIA_SUPPORTED, "unknown-provider", "some-model")) + .isInstanceOf(RequestException.class) + .hasFieldOrPropertyWithValue("code", RequestException.Code.INVALID_RERANK_OVERRIDE.name()) + .hasMessageContaining("unknown-provider"); + } + + @Test + void shouldRejectDisabledProvider() { + var disabledConfig = + configWithProvider( + "nvidia", + false, + List.of(modelConfig("nvidia/rerank-v1", ApiModelSupport.SupportStatus.SUPPORTED))); + + assertThatThrownBy( + () -> builder.validateRerankOverride(disabledConfig, "nvidia", "nvidia/rerank-v1")) + .isInstanceOf(RequestException.class) + .hasFieldOrPropertyWithValue("code", RequestException.Code.INVALID_RERANK_OVERRIDE.name()) + .hasMessageContaining("disabled"); + } + + @Test + void shouldRejectNullModelName() { + assertThatThrownBy(() -> builder.validateRerankOverride(NVIDIA_SUPPORTED, "nvidia", null)) + .isInstanceOf(RequestException.class) + .hasFieldOrPropertyWithValue("code", RequestException.Code.INVALID_RERANK_OVERRIDE.name()) + .hasMessageContaining("modelName"); + } + + @Test + void shouldRejectUnknownModel() { + assertThatThrownBy( + () -> + builder.validateRerankOverride( + NVIDIA_SUPPORTED, "nvidia", "nvidia/nonexistent-model")) + .isInstanceOf(RequestException.class) + .hasFieldOrPropertyWithValue("code", RequestException.Code.INVALID_RERANK_OVERRIDE.name()) + .hasMessageContaining("nonexistent-model"); + } + + @Test + void shouldRejectDeprecatedModel() { + var config = + configWithProvider( + "nvidia", + true, + List.of(modelConfig("nvidia/old-model", ApiModelSupport.SupportStatus.DEPRECATED))); + + assertThatThrownBy(() -> builder.validateRerankOverride(config, "nvidia", "nvidia/old-model")) + .isInstanceOf(SchemaException.class) + .hasFieldOrPropertyWithValue("code", SchemaException.Code.DEPRECATED_AI_MODEL.name()); + } + + @Test + void shouldRejectEndOfLifeModel() { + var config = + configWithProvider( + "nvidia", + true, + List.of(modelConfig("nvidia/eol-model", ApiModelSupport.SupportStatus.END_OF_LIFE))); + + assertThatThrownBy(() -> builder.validateRerankOverride(config, "nvidia", "nvidia/eol-model")) + .isInstanceOf(SchemaException.class) + .hasFieldOrPropertyWithValue("code", SchemaException.Code.END_OF_LIFE_AI_MODEL.name()); + } + + @Test + void shouldRejectUnknownProviderBeforeCheckingModelName() { + // When both provider is unknown AND modelName is null, the provider check should + // come first — user gets the more actionable "provider not supported" error + assertThatThrownBy( + () -> builder.validateRerankOverride(NVIDIA_SUPPORTED, "unknown-provider", null)) + .isInstanceOf(RequestException.class) + .hasFieldOrPropertyWithValue("code", RequestException.Code.INVALID_RERANK_OVERRIDE.name()) + .hasMessageContaining("unknown-provider") + .hasMessageContaining("not supported"); + } + + @Test + void shouldRejectDisabledProviderBeforeCheckingModelName() { + // When provider is disabled AND modelName is null, the disabled check should + // come first — user gets "provider disabled" instead of "modelName required" + var disabledConfig = + configWithProvider( + "nvidia", + false, + List.of(modelConfig("nvidia/rerank-v1", ApiModelSupport.SupportStatus.SUPPORTED))); + + assertThatThrownBy(() -> builder.validateRerankOverride(disabledConfig, "nvidia", null)) + .isInstanceOf(RequestException.class) + .hasFieldOrPropertyWithValue("code", RequestException.Code.INVALID_RERANK_OVERRIDE.name()) + .hasMessageContaining("disabled"); + } + } +}