Skip to content
Open
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 @@ -341,8 +341,8 @@ static void validateModelFields(List<FunctionModelDto> models,
throw new BadRequestException(MESG_MISSING_LLM_MODEL_URIS);
}
if (isLlmFunction) {
LlmConfigValidator.validateRoutingMethod(
model.getName(), model.getLlmConfig().getRoutingMethod());
model.getLlmConfig().setRoutingMethod(LlmRoutingMethodValidator.validate(
model.getName(), model.getLlmConfig().getRoutingMethod()));
LlmConfigValidator.validateTokenRateLimit(
model.getName(), model.getLlmConfig().getTokenRateLimit());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,68 +6,36 @@

import com.nvidia.boot.exceptions.BadRequestException;
import jakarta.annotation.Nullable;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

/**
* Rejects invalid {@code llmConfig} routingMethod/tokenRateLimit at create/update, so callers
* get a 400 up front instead of a late failure at invocation.
* Rejects an invalid {@code llmConfig} tokenRateLimit at create/update, so callers get a 400 up
* front instead of a late failure at invocation.
*/
@Slf4j
public final class LlmConfigValidator {

private LlmConfigValidator() {}

// Stargate LoadBalancerAlgorithm values; keep in sync. Blank = router default.
private static final Set<String> VALID_ROUTING_METHODS = Set.of(
"power-of-two",
"wait-and-widen",
"round-robin",
"random",
"pulsar",
"pulsar-wait-and-widen",
// Deprecated Stargate aliases retained for existing deployments.
"groq-multiregion",
"pulsar-multiregion");

// Comma-separated '<positiveInteger>-<unit>' entries, no unit repeated.
private static final Pattern TOKEN_RATE_LIMIT_PATTERN = Pattern.compile(
"^(?!.*-([SMHDW]).*-\\1)[1-9]\\d*-[SMHDW](,\\s*[1-9]\\d*-[SMHDW])*$");

private static final String MESG_INVALID_ROUTING_METHOD =
"Invalid request: 'llmConfig.routingMethod' for model '%s' is invalid; supported "
+ "values are [power-of-two, wait-and-widen, round-robin, random, pulsar, "
+ "pulsar-wait-and-widen, groq-multiregion, pulsar-multiregion]";
private static final String MESG_INVALID_TOKEN_RATE_LIMIT =
"Invalid request: 'llmConfig.tokenRateLimit' for model '%s' is invalid; expected "
+ "comma-separated '<positiveInteger>-<unit>' entries with unit in [S, M, H, D, W] "
+ "(for example '100000-S' or '10-M,5-S')";

/** Rejects a routingMethod that is not one of the supported router algorithms. */
public static void validateRoutingMethod(String modelName, @Nullable String routingMethod) {
if (StringUtils.isBlank(routingMethod)) {
return;
}
// Match the router: lowercase, '_' -> '-'.
var normalized = routingMethod.trim().toLowerCase(Locale.ROOT).replace('_', '-');
if (!VALID_ROUTING_METHODS.contains(normalized)) {
var mesg = MESG_INVALID_ROUTING_METHOD.formatted(modelName);
log.error(mesg);
throw new BadRequestException(mesg);
}
}

/** Rejects a tokenRateLimit that is not '<positiveInteger>-<unit>' fragments. */
public static void validateTokenRateLimit(String modelName, @Nullable String tokenRateLimit) {
if (StringUtils.isBlank(tokenRateLimit)) {
return;
}
if (!TOKEN_RATE_LIMIT_PATTERN.matcher(tokenRateLimit).matches()) {
var mesg = MESG_INVALID_TOKEN_RATE_LIMIT.formatted(modelName);
log.error(mesg);
log.warn(mesg);
throw new BadRequestException(mesg);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
* SPDX-License-Identifier: Apache-2.0
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.nvidia.nvcf.rest.function.management.dto;

import com.nvidia.boot.exceptions.BadRequestException;
import jakarta.annotation.Nullable;
import java.nio.charset.StandardCharsets;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

/**
* Validates the syntax of {@code llmConfig.routingMethod} at create/update: a method name
* optionally followed by {@code ;key=value} parameters. Methods and parameters are not
* interpreted here; the router owns their semantics and the value is stored as received.
*/
@Slf4j
public final class LlmRoutingMethodValidator {

private static final int MAX_EXPRESSION_BYTES = 1024;
private static final int MAX_PARAMETERS = 32;
private static final Pattern METHOD_PATTERN = Pattern.compile("[A-Za-z][A-Za-z0-9_-]*");
private static final Pattern PARAMETER_PATTERN =
Pattern.compile(" *([a-z][a-z0-9_]*)=(\\S(?:.*\\S)?)");
private static final Pattern INTEGER_PATTERN = Pattern.compile("-?[0-9]{1,15}");
private static final Pattern DECIMAL_PATTERN = Pattern.compile("-?[0-9]{1,12}\\.[0-9]{1,3}");
private static final Pattern TOKEN_PATTERN =
Pattern.compile("[A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]*");
private static final Pattern STRING_PATTERN = Pattern.compile(
"\"(?:[\\x20\\x21\\x23-\\x2b\\x2d-\\x3a\\x3c-\\x5b\\x5d-\\x7e]|\\\\[\"\\\\])*\"");
// \p{Cc} covers the C1 controls such as U+0085 (NEL), which \p{Cntrl} does not.
private static final Pattern CONTROL_CHARACTERS =
Pattern.compile("[\\p{Cc}\\p{Zl}\\p{Zp}]");

private static final String MESG_INVALID_ROUTING_METHOD =
"Invalid request: 'llmConfig.routingMethod' for model '%s' is invalid: %s";
private static final String MESG_EXPRESSION_TOO_LONG =
"expression exceeds %d bytes".formatted(MAX_EXPRESSION_BYTES);
private static final String MESG_COMMAS_NOT_ALLOWED = "commas are not allowed";
private static final String MESG_INVALID_METHOD_NAME =
"method name must match [A-Za-z][A-Za-z0-9_-]*";
private static final String MESG_TOO_MANY_PARAMETERS =
"at most %d parameters are allowed".formatted(MAX_PARAMETERS);
private static final String MESG_INVALID_PARAMETER =
"parameter '%s' must be key=value with key matching [a-z][a-z0-9_]*";
private static final String MESG_INVALID_VALUE =
"value for '%s' must be an integer, decimal, token, or quoted string";
private static final String MESG_DUPLICATE_PARAMETER = "duplicate parameter '%s'";

private LlmRoutingMethodValidator() {}

/**
* Rejects a routingMethod whose syntax the router could not parse and returns the value to
* store: the input without outer spaces, so validated and stored bytes are identical. Any
* other outer character, including tabs and line breaks, fails the grammar.
*/
@Nullable
public static String validate(String modelName, @Nullable String routingMethod) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design note rather than a bug. validate both checks and canonicalises, and the canonical bytes only reach storage if the caller stores the return value. Two of the four writers do (validateModelFields, updateModels); reconcileModelLlmConfigAcrossVersions and FunctionMapperService.applyLlmConfigOverrides copy whatever they are handed and rely on the Jackson converter having already mutated the DTO. That works today, but any future path that builds a FunctionModelDto programmatically (clone, migration, gRPC import) gets no signal that it skipped normalisation. Trimming at the DTO boundary (a field-level converter or setter on LlmConfigDto.routingMethod) would let every path see the same bytes and make this method a pure check.

if (routingMethod == null) {
return null;
}
var value = StringUtils.strip(routingMethod, " ");
if (value.isEmpty()) {
return value;
}
if (value.getBytes(StandardCharsets.UTF_8).length > MAX_EXPRESSION_BYTES) {
reject(modelName, MESG_EXPRESSION_TOO_LONG);
}
if (value.contains(",")) {
reject(modelName, MESG_COMMAS_NOT_ALLOWED);
}
var segments = value.split(";", -1);
if (!METHOD_PATTERN.matcher(segments[0]).matches()) {
reject(modelName, MESG_INVALID_METHOD_NAME);
}
if (segments.length - 1 > MAX_PARAMETERS) {
reject(modelName, MESG_TOO_MANY_PARAMETERS);
}
var keys = new HashSet<String>();
for (var index = 1; index < segments.length; index++) {
validateParameter(modelName, segments[index], keys);
}
return value;
}

private static void validateParameter(String modelName, String segment, Set<String> keys) {
var parameter = PARAMETER_PATTERN.matcher(segment);
if (!parameter.matches()) {
reject(modelName, MESG_INVALID_PARAMETER.formatted(withoutControlCharacters(segment)));
}
var key = parameter.group(1);
if (!isValidBareValue(parameter.group(2))) {
reject(modelName, MESG_INVALID_VALUE.formatted(key));
}
if (!keys.add(key)) {
reject(modelName, MESG_DUPLICATE_PARAMETER.formatted(key));
}
}

// The segment is raw request text echoed in the log and the 400 body; a line break in it
// could forge a log line.
private static String withoutControlCharacters(String segment) {
return CONTROL_CHARACTERS.matcher(segment).replaceAll("?");
}

private static boolean isValidBareValue(String value) {
return INTEGER_PATTERN.matcher(value).matches()
|| DECIMAL_PATTERN.matcher(value).matches()
|| TOKEN_PATTERN.matcher(value).matches()
|| STRING_PATTERN.matcher(value).matches();
}

// A malformed client value is not an operator problem, so it is logged below error level.
private static void reject(String modelName, String rule) {
var mesg = MESG_INVALID_ROUTING_METHOD.formatted(withoutControlCharacters(modelName), rule);
log.warn(mesg);
throw new BadRequestException(mesg);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import com.nvidia.nvcf.rest.function.management.dto.FunctionTypeEnum;
import com.nvidia.nvcf.rest.function.management.dto.LlmConfigValidator;
import com.nvidia.nvcf.rest.function.management.dto.LlmInvocationConfigDto;
import com.nvidia.nvcf.rest.function.management.dto.LlmRoutingMethodValidator;
import com.nvidia.nvcf.rest.function.management.dto.UpdateFunctionRequest;
import jakarta.annotation.Nullable;
import java.util.Comparator;
Expand Down Expand Up @@ -264,7 +265,8 @@ private Map<UUID, FunctionEntity> propagateModelUpdatesToSiblings(
UpdateFunctionRequest.ModelUpdateDto::modelName,
u -> FunctionModelDto.LlmConfigDto.builder()
.tokenRateLimit(u.llmConfig().tokenRateLimit())
.routingMethod(u.llmConfig().routingMethod())
.routingMethod(LlmRoutingMethodValidator.validate(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This re-validates the same raw request values that updateModels already validated and stored a few lines earlier in applyLlmUpdates. Harmless today, but the two sites can drift. Validating once up front into a Map<String, LlmConfigDto> of canonical values, then feeding both updateModels and this propagation from it, removes the duplicate parse.

u.modelName(), u.llmConfig().routingMethod()))
.build()));
if (overrides.isEmpty()) {
return Map.of();
Expand Down Expand Up @@ -403,9 +405,8 @@ private void updateModels(
llmConfig.setTokenRateLimit(llmConfigUpdate.tokenRateLimit());
}
if (llmConfigUpdate.routingMethod() != null) {
LlmConfigValidator.validateRoutingMethod(
modelUpdate.modelName(), llmConfigUpdate.routingMethod());
llmConfig.setRoutingMethod(llmConfigUpdate.routingMethod());
llmConfig.setRoutingMethod(LlmRoutingMethodValidator.validate(
modelUpdate.modelName(), llmConfigUpdate.routingMethod()));
}
updated = true;
break;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,55 @@ private FunctionDto createAdditionalLlmFunctionVersion(
return response.getBody().function();
}

@Test
void shouldStoreRoutingExpressionsWithoutOuterSpacesOnCreateAndUpdate() {
var functionName = TEST_FUNCTION_NAME + "-" + Instant.now().toEpochMilli();
var storedRoutingMethod = "Pulsar_Wait_And_Widen; seed=stable-a;n=2";
var function = createInitialLlmFunction(
functionName, "1-M", " " + storedRoutingMethod + " ");

assertThat(function.models().getFirst().getLlmConfig().getRoutingMethod())
.isEqualTo(storedRoutingMethod);
assertLlmConfigPersisted(function.versionId(), "1-M", storedRoutingMethod);

// A different value forces the sibling write, which copies the converter's trimmed DTO.
var secondRoutingMethod = "wait-and-widen;n=3";
var secondVersion = createAdditionalLlmFunctionVersion(
function.id(), functionName, "1-M", " " + secondRoutingMethod + " ");
assertThat(secondVersion.models().getFirst().getLlmConfig().getRoutingMethod())
.isEqualTo(secondRoutingMethod);
assertLlmConfigPersisted(function.versionId(), "1-M", secondRoutingMethod);
assertLlmConfigPersisted(secondVersion.versionId(), "1-M", secondRoutingMethod);

// Unknown method and parameter: well formed, so it persists; the router owns semantics.
var updatedRoutingMethod = "fastest;widen=2";
var updateToken = MOCK_OAUTH2_TOKEN_SERVER.getJwt(
TEST_CLIENT_SUBJECT, List.of(SCOPE_UPDATE_FUNCTION), 100);
var updateRequest = UpdateFunctionRequest.builder()
.modelUpdates(List.of(UpdateFunctionRequest.ModelUpdateDto.builder()
.modelName(TEST_LLM_MODEL_NAME)
.llmConfig(UpdateFunctionRequest.LlmConfigUpdateDto.builder()
.routingMethod(" " + updatedRoutingMethod + " ")
.build())
.build()))
.build();
var updateEntity = RequestEntity.put(URI.create("/v2/nvcf/functions/" + function.id()
+ "/versions/" + function.versionId()))
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + updateToken)
.body(updateRequest);

var response = testRestTemplate.exchange(updateEntity, FunctionResponse.class);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
var updatedModel = response.getBody().function().models().getFirst();
assertThat(updatedModel.getLlmConfig().getRoutingMethod())
.isEqualTo(updatedRoutingMethod);
assertLlmConfigPersisted(function.versionId(), "1-M", updatedRoutingMethod);
assertLlmConfigPersisted(secondVersion.versionId(), "1-M", updatedRoutingMethod);
}

@Test
void shouldRejectCreateWithInvalidRoutingMethod() {
var createToken = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT,
Expand All @@ -605,7 +654,7 @@ void shouldRejectCreateWithInvalidRoutingMethod() {
.inferenceUrl(TEST_INFERENCE_URL)
.inferencePort(TEST_INFERENCE_PORT)
.functionType(FunctionTypeEnum.LLM)
.models(List.of(llmModel("1-M", "not-a-method")))
.models(List.of(llmModel("1-M", "pulsar,seed=x")))
.build();
var createEntity = RequestEntity.post(URI.create("/v2/nvcf/functions"))
.contentType(MediaType.APPLICATION_JSON)
Expand All @@ -615,7 +664,36 @@ void shouldRejectCreateWithInvalidRoutingMethod() {
var response = testRestTemplate.exchange(createEntity, String.class);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains("llmConfig.routingMethod");
assertThat(response.getBody()).contains(
"llmConfig.routingMethod", TEST_LLM_MODEL_NAME, "commas are not allowed");
}

@Test
void shouldRejectVersionCreateWithInvalidRoutingMethod() {
var functionName = TEST_FUNCTION_NAME + "-" + Instant.now().toEpochMilli();
var function = createInitialLlmFunction(functionName, "1-M", "round-robin");

var createToken = MOCK_OAUTH2_TOKEN_SERVER.getJwt(TEST_CLIENT_SUBJECT,
List.of(SCOPE_REGISTER_FUNCTION), 100);
var createRequest = CreateFunctionRequest.builder()
.name(functionName)
.containerImage(TEST_NGC_CONTAINER_IMAGE)
.inferenceUrl(TEST_INFERENCE_URL)
.inferencePort(TEST_INFERENCE_PORT)
.functionType(FunctionTypeEnum.LLM)
.models(List.of(llmModel("1-M", "pulsar;seed=")))
.build();
var createEntity = RequestEntity.post(URI.create(
"/v2/nvcf/functions/" + function.id() + "/versions"))
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer " + createToken)
.body(createRequest);

var response = testRestTemplate.exchange(createEntity, String.class);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains(
"llmConfig.routingMethod", TEST_LLM_MODEL_NAME, "must be key=value");
}

@Test
Expand Down Expand Up @@ -652,7 +730,7 @@ void shouldRejectUpdateWithInvalidRoutingMethod() {
.modelUpdates(List.of(UpdateFunctionRequest.ModelUpdateDto.builder()
.modelName(TEST_LLM_MODEL_NAME)
.llmConfig(UpdateFunctionRequest.LlmConfigUpdateDto.builder()
.routingMethod("not-a-method")
.routingMethod("pulsar;n=?1")
.build())
.build()))
.build();
Expand All @@ -665,7 +743,8 @@ void shouldRejectUpdateWithInvalidRoutingMethod() {
var response = testRestTemplate.exchange(updateEntity, String.class);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).contains("llmConfig.routingMethod");
assertThat(response.getBody()).contains(
"llmConfig.routingMethod", TEST_LLM_MODEL_NAME, "value for 'n'");
}

@Test
Expand Down
Loading
Loading