Skip to content
Closed
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 @@ -180,6 +180,7 @@ public class OpenAiChatOptions implements ToolCallingChatOptions {
* Whether to store the output of this chat completion request for use in our model <a href="https://platform.openai.com/docs/guides/distillation">distillation</a> or <a href="https://platform.openai.com/docs/guides/evals">evals</a> products.
*/
private @JsonProperty("store") Boolean store;

/**
* Developer-defined tags and values used for filtering completions in the <a href="https://platform.openai.com/chat-completions">dashboard</a>.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
* <p>
* Use the {@link #builder()} to create and configure instances.
*
* <p>
* <b>Thread safety:</b> This class is safe for concurrent use. Each call to
* {@link #apply(String, Map)} creates a new StringTemplate instance, and no mutable state
* is shared between threads.
*
* @author Thomas Vitale
* @since 1.0.0
*/
Expand All @@ -57,23 +62,35 @@ public class StTemplateRenderer implements TemplateRenderer {

private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;

private static final boolean DEFAULT_SUPPORT_ST_FUNCTIONS = false;
private static final boolean DEFAULT_VALIDATE_ST_FUNCTIONS = false;

private final char startDelimiterToken;

private final char endDelimiterToken;

private final ValidationMode validationMode;

private final boolean supportStFunctions;
private final boolean validateStFunctions;

StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode,
boolean supportStFunctions) {
/**
* Constructs a new {@code StTemplateRenderer} with the specified delimiter tokens,
* validation mode, and function validation flag.
* @param startDelimiterToken the character used to denote the start of a template
* variable (e.g., '{')
* @param endDelimiterToken the character used to denote the end of a template
* variable (e.g., '}')
* @param validationMode the mode to use for template variable validation; must not be
* null
* @param validateStFunctions whether to validate StringTemplate functions in the
* template
*/
public StTemplateRenderer(char startDelimiterToken, char endDelimiterToken, ValidationMode validationMode,
boolean validateStFunctions) {
Assert.notNull(validationMode, "validationMode cannot be null");
this.startDelimiterToken = startDelimiterToken;
this.endDelimiterToken = endDelimiterToken;
this.validationMode = validationMode;
this.supportStFunctions = supportStFunctions;
this.validateStFunctions = validateStFunctions;
}

@Override
Expand Down Expand Up @@ -101,20 +118,28 @@ private ST createST(String template) {
}
}

private void validate(ST st, Map<String, Object> templateVariables) {
/**
* Validates that all required template variables are provided in the model. Returns
* the set of missing variables for further handling or logging.
* @param st the StringTemplate instance
* @param templateVariables the provided variables
* @return set of missing variable names, or empty set if none are missing
*/
private Set<String> validate(ST st, Map<String, Object> templateVariables) {
Set<String> templateTokens = getInputVariables(st);
Set<String> modelKeys = templateVariables != null ? templateVariables.keySet() : new HashSet<>();
Set<String> missingVariables = new HashSet<>(templateTokens);
missingVariables.removeAll(modelKeys);

// Check if model provides all keys required by the template
if (!modelKeys.containsAll(templateTokens)) {
templateTokens.removeAll(modelKeys);
if (!missingVariables.isEmpty()) {
if (validationMode == ValidationMode.WARN) {
logger.warn(VALIDATION_MESSAGE.formatted(templateTokens));
logger.warn(VALIDATION_MESSAGE.formatted(missingVariables));
}
else if (validationMode == ValidationMode.THROW) {
throw new IllegalStateException(VALIDATION_MESSAGE.formatted(templateTokens));
throw new IllegalStateException(VALIDATION_MESSAGE.formatted(missingVariables));
}
}
return missingVariables;
}

private Set<String> getInputVariables(ST st) {
Expand All @@ -125,11 +150,12 @@ private Set<String> getInputVariables(ST st) {
for (int i = 0; i < tokens.size(); i++) {
Token token = tokens.get(i);

// Handle list variables with option (e.g., {items; separator=", "})
if (token.getType() == STLexer.LDELIM && i + 1 < tokens.size()
&& tokens.get(i + 1).getType() == STLexer.ID) {
if (i + 2 < tokens.size() && tokens.get(i + 2).getType() == STLexer.COLON) {
String text = tokens.get(i + 1).getText();
if (!Compiler.funcs.containsKey(text) || !supportStFunctions) {
if (!Compiler.funcs.containsKey(text) || this.validateStFunctions) {
inputVariables.add(text);
isInsideList = true;
}
Expand All @@ -138,13 +164,19 @@ private Set<String> getInputVariables(ST st) {
else if (token.getType() == STLexer.RDELIM) {
isInsideList = false;
}
// Only add IDs that are not function calls (i.e., not immediately followed by
else if (!isInsideList && token.getType() == STLexer.ID) {
if (!Compiler.funcs.containsKey(token.getText()) || !supportStFunctions) {
boolean isFunctionCall = (i + 1 < tokens.size() && tokens.get(i + 1).getType() == STLexer.LPAREN);
boolean isDotProperty = (i > 0 && tokens.get(i - 1).getType() == STLexer.DOT);
// Only add as variable if:
// - Not a function call
// - Not a built-in function used as property (unless validateStFunctions)
if (!isFunctionCall && (!Compiler.funcs.containsKey(token.getText()) || this.validateStFunctions
|| !(isDotProperty && Compiler.funcs.containsKey(token.getText())))) {
inputVariables.add(token.getText());
}
}
}

return inputVariables;
}

Expand All @@ -163,7 +195,7 @@ public static class Builder {

private ValidationMode validationMode = DEFAULT_VALIDATION_MODE;

private boolean supportStFunctions = DEFAULT_SUPPORT_ST_FUNCTIONS;
private boolean validateStFunctions = DEFAULT_VALIDATE_ST_FUNCTIONS;

private Builder() {
}
Expand Down Expand Up @@ -215,8 +247,8 @@ public Builder validationMode(ValidationMode validationMode) {
* ({@link ValidationMode#WARN} or {@link ValidationMode#THROW}).
* @return This builder instance for chaining.
*/
public Builder supportStFunctions() {
this.supportStFunctions = true;
public Builder validateStFunctions() {
this.validateStFunctions = true;
return this;
}

Expand All @@ -226,7 +258,7 @@ public Builder supportStFunctions() {
* @return A configured {@link StTemplateRenderer}.
*/
public StTemplateRenderer build() {
return new StTemplateRenderer(startDelimiterToken, endDelimiterToken, validationMode, supportStFunctions);
return new StTemplateRenderer(startDelimiterToken, endDelimiterToken, validationMode, validateStFunctions);
}

}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* 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
*
* https://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.
*/

@NonNullApi
@NonNullFields
package org.springframework.ai.template.st;

import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/*
* Copyright 2023-2025 the original author or authors.
*
* 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
*
* https://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 org.springframework.ai.template.st;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.util.HashMap;
import java.util.Map;

import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.ai.template.ValidationMode;

/**
* Additional edge and robustness tests for {@link StTemplateRenderer}.
*/
class StTemplateRendererEdgeTests {

// --- Built-in Function Handling Tests START ---

/**
* Built-in functions (first, last) are rendered correctly with variables.
*/
@Test
void shouldHandleMultipleBuiltInFunctionsAndVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("list", java.util.Arrays.asList("a", "b", "c"));
variables.put("name", "Mark");
String template = "{name}: {first(list)}, {last(list)}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("Mark: a, c");
}

/**
* Nested and chained built-in functions are handled when validation is enabled.
*/
/**
* Confirms that ST4 supports valid nested function expressions.
*/
@Test
void shouldSupportValidNestedFunctionExpressionInST4() {
Map<String, Object> variables = new HashMap<>();
variables.put("words", java.util.Arrays.asList("hello", "WORLD"));
String template = "{first(words)} {last(words)} {length(words)}";
StTemplateRenderer defaultRenderer = StTemplateRenderer.builder().build();
String defaultResult = defaultRenderer.apply(template, variables);
assertThat(defaultResult).isEqualTo("hello WORLD 2");
}

/**
* Nested and chained built-in functions are handled when validation is enabled.
*/
@Test
void shouldHandleNestedBuiltInFunctions() {
Map<String, Object> variables = new HashMap<>();
variables.put("words", java.util.Arrays.asList("hello", "WORLD"));
String template = "{first(words)} {last(words)} {length(words)}";
StTemplateRenderer renderer = StTemplateRenderer.builder().validateStFunctions().build();
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("hello WORLD 2");
}

/**
* Built-in functions as properties are rendered correctly if supported.
*/
@Test
@Disabled("It is very hard to validate the template expression when using property style access of built-in functions ")
void shouldSupportBuiltInFunctionsAsProperties() {
Map<String, Object> variables = new HashMap<>();
variables.put("words", java.util.Arrays.asList("hello", "WORLD"));
String template = "{words.first} {words.last} {words.length}";
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("hello WORLD 2");
}

/**
* Built-in functions are not reported as missing variables in THROW mode.
*/
@Test
void shouldNotReportBuiltInFunctionsAsMissingVariablesInThrowMode() {
StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.THROW).build();
Map<String, Object> variables = new HashMap<>();
variables.put("memory", "abc");
String template = "{if(strlen(memory))}ok{endif}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("ok");
}

/**
* Built-in functions are not reported as missing variables in WARN mode.
*/
@Test
void shouldNotReportBuiltInFunctionsAsMissingVariablesInWarnMode() {
StTemplateRenderer renderer = StTemplateRenderer.builder().validationMode(ValidationMode.WARN).build();
Map<String, Object> variables = new HashMap<>();
variables.put("memory", "abc");
String template = "{if(strlen(memory))}ok{endif}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("ok");
}

/**
* Variables with names similar to built-in functions are treated as normal variables.
*/
@Test
void shouldHandleVariableNamesSimilarToBuiltInFunctions() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("lengthy", "foo");
variables.put("firstName", "bar");
String template = "{lengthy} {firstName}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("foo bar");
}

// --- Built-in Function Handling Tests END ---

@Test
void shouldRenderEscapedDelimiters() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("x", "y");
String template = "{x} \\{foo\\}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("y {foo}");
}

@Test
void shouldRenderStaticTextTemplate() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
String template = "Just static text.";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("Just static text.");
}

// Duplicate removed: shouldHandleVariableNamesSimilarToBuiltInFunctions
// (now grouped at the top of the class)

@Test
void shouldHandleLargeNumberOfVariables() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
StringBuilder template = new StringBuilder();
for (int i = 0; i < 100; i++) {
String key = "var" + i;
variables.put(key, i);
template.append("{" + key + "} ");
}
String result = renderer.apply(template.toString().trim(), variables);
StringBuilder expected = new StringBuilder();
for (int i = 0; i < 100; i++) {
expected.append(i).append(" ");
}
assertThat(result).isEqualTo(expected.toString().trim());
}

@Test
void shouldRenderUnicodeAndSpecialCharacters() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("emoji", "😀");
variables.put("accented", "Café");
String template = "{emoji} {accented}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("😀 Café");
}

@Test
void shouldRenderNullVariableValuesAsBlank() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("foo", null);
String template = "Value: {foo}";
String result = renderer.apply(template, variables);
assertThat(result).isEqualTo("Value: ");
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,11 @@ void shouldHandleObjectVariables() {

/**
* Test whether StringTemplate can correctly render a template containing built-in
* functions when {@code supportStFunctions()} is enabled. It should render properly.
* functions. It should render properly.
*/
@Test
void shouldRenderTemplateWithSupportStFunctions() {
StTemplateRenderer renderer = StTemplateRenderer.builder().supportStFunctions().build();
void shouldRenderTemplateWithBuiltInFunctions() {
StTemplateRenderer renderer = StTemplateRenderer.builder().build();
Map<String, Object> variables = new HashMap<>();
variables.put("memory", "you are a helpful assistant");
String template = "{if(strlen(memory))}Hello!{endif}";
Expand Down