diff --git a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java
index c5687add88d..9439c041250 100644
--- a/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java
+++ b/models/spring-ai-openai/src/main/java/org/springframework/ai/openai/OpenAiChatOptions.java
@@ -180,6 +180,7 @@ public class OpenAiChatOptions implements ToolCallingChatOptions {
* Whether to store the output of this chat completion request for use in our model distillation or evals products.
*/
private @JsonProperty("store") Boolean store;
+
/**
* Developer-defined tags and values used for filtering completions in the dashboard.
*/
diff --git a/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java
index fbc186aeb9e..a7f3c9c5475 100644
--- a/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java
+++ b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java
@@ -42,6 +42,11 @@
*
* Use the {@link #builder()} to create and configure instances.
*
+ *
+ * Thread safety: 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
*/
@@ -57,7 +62,7 @@ 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;
@@ -65,15 +70,27 @@ public class StTemplateRenderer implements TemplateRenderer {
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
@@ -101,20 +118,28 @@ private ST createST(String template) {
}
}
- private void validate(ST st, Map 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 validate(ST st, Map templateVariables) {
Set templateTokens = getInputVariables(st);
Set modelKeys = templateVariables != null ? templateVariables.keySet() : new HashSet<>();
+ Set 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 getInputVariables(ST st) {
@@ -125,11 +150,12 @@ private Set 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;
}
@@ -138,13 +164,19 @@ private Set 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;
}
@@ -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() {
}
@@ -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;
}
@@ -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);
}
}
diff --git a/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/package-info.java b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/package-info.java
new file mode 100644
index 00000000000..449bd8e0399
--- /dev/null
+++ b/spring-ai-template-st/src/main/java/org/springframework/ai/template/st/package-info.java
@@ -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;
diff --git a/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererEdgeTests.java b/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererEdgeTests.java
new file mode 100644
index 00000000000..56feb7a7071
--- /dev/null
+++ b/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererEdgeTests.java
@@ -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 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 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 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 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 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 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 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 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 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 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 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 variables = new HashMap<>();
+ variables.put("foo", null);
+ String template = "Value: {foo}";
+ String result = renderer.apply(template, variables);
+ assertThat(result).isEqualTo("Value: ");
+ }
+
+}
diff --git a/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java b/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java
index b887feb30d8..7d5f86d71c6 100644
--- a/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java
+++ b/spring-ai-template-st/src/test/java/org/springframework/ai/template/st/StTemplateRendererTests.java
@@ -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 variables = new HashMap<>();
variables.put("memory", "you are a helpful assistant");
String template = "{if(strlen(memory))}Hello!{endif}";