-
Notifications
You must be signed in to change notification settings - Fork 72
feat(cloud-functions): accept routing expressions in routingMethod #1955
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
dad48eb
25d9a20
a348948
a886f3f
a76e656
a8d18e7
079d445
16699ae
b7d4a7f
6243ee7
b4a75bc
d4bc6dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) { | ||
| 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 |
|---|---|---|
|
|
@@ -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; | ||
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This re-validates the same raw request values that |
||
| u.modelName(), u.llmConfig().routingMethod())) | ||
| .build())); | ||
| if (overrides.isEmpty()) { | ||
| return Map.of(); | ||
|
|
@@ -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; | ||
|
|
||
There was a problem hiding this comment.
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.
validateboth 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);reconcileModelLlmConfigAcrossVersionsandFunctionMapperService.applyLlmConfigOverridescopy whatever they are handed and rely on the Jackson converter having already mutated the DTO. That works today, but any future path that builds aFunctionModelDtoprogrammatically (clone, migration, gRPC import) gets no signal that it skipped normalisation. Trimming at the DTO boundary (a field-level converter or setter onLlmConfigDto.routingMethod) would let every path see the same bytes and make this method a pure check.