fix(llm): preserve numeric tool schemas (#594)

This commit is contained in:
mateaix 2026-08-09 21:31:42 +08:00
parent 763fb554da
commit 5e96dade9c
3 changed files with 229 additions and 1 deletions

View File

@ -281,6 +281,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
public org.springframework.http.ResponseEntity<OpenAiApi.ChatCompletion> chatCompletionEntity(
OpenAiApi.ChatCompletionRequest chatRequest,
MultiValueMap<String, String> additionalHttpHeader) {
chatRequest = OpenAiRequestRewriter.preserveToolSchemaNumbers(chatRequest);
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
@ -302,6 +303,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
public Flux<OpenAiApi.ChatCompletionChunk> chatCompletionStream(
OpenAiApi.ChatCompletionRequest chatRequest,
MultiValueMap<String, String> additionalHttpHeader) {
chatRequest = OpenAiRequestRewriter.preserveToolSchemaNumbers(chatRequest);
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);

View File

@ -5,6 +5,7 @@ import org.springframework.ai.openai.api.OpenAiApi;
import vip.mate.llm.model.ModelFamily;
import vip.mate.llm.model.ModelProviderEntity;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@ -22,13 +23,97 @@ import java.util.Map;
* <p>The rewrites exist because OpenAI-compatible providers diverge in ways
* Spring AI's {@code OpenAiChatOptions} cannot express reasoning-content
* replay contracts, reasoning-effort acceptance, strict tool-choice validation,
* video media encoding, and Kimi's built-in web search tool.
* JSON Schema number preservation, video media encoding, and Kimi's built-in
* web search tool.
*/
@Slf4j
final class OpenAiRequestRewriter {
private OpenAiRequestRewriter() {}
// ==================== tool schema number preservation ====================
/**
* Keep integral JSON Schema values numeric on the OpenAI wire.
*
* <p>Spring AI parses every tool's schema string into a nested {@link Map}.
* Values beyond {@link Integer#MAX_VALUE} consequently become {@link Long}s.
* MateClaw's application-wide Jackson configuration intentionally serializes
* {@code Long} as strings to protect Snowflake IDs from JavaScript precision
* loss, but that policy must not leak into protocol metadata: providers reject
* schemas such as {@code "maximum":"9007199254740991"} because JSON Schema
* requires {@code maximum} to be a number.
*
* <p>Replace Long values inside tool parameter schemas with numerically
* equivalent {@link BigInteger}s. Jackson still emits those as JSON numbers,
* while the global Long-to-string policy remains intact for application DTOs.
*/
static OpenAiApi.ChatCompletionRequest preserveToolSchemaNumbers(
OpenAiApi.ChatCompletionRequest request) {
if (request.tools() == null || request.tools().isEmpty()) {
return request;
}
boolean changed = false;
List<OpenAiApi.FunctionTool> tools = new ArrayList<>(request.tools().size());
for (OpenAiApi.FunctionTool tool : request.tools()) {
if (tool == null || tool.getFunction() == null
|| tool.getFunction().getParameters() == null) {
tools.add(tool);
continue;
}
Object normalized = preserveSchemaNumber(tool.getFunction().getParameters());
if (normalized == tool.getFunction().getParameters()) {
tools.add(tool);
continue;
}
OpenAiApi.FunctionTool.Function original = tool.getFunction();
@SuppressWarnings("unchecked")
Map<String, Object> parameters = (Map<String, Object>) normalized;
OpenAiApi.FunctionTool.Function function = new OpenAiApi.FunctionTool.Function(
original.getDescription(), original.getName(), parameters, original.getStrict());
tools.add(new OpenAiApi.FunctionTool(tool.getType(), function));
changed = true;
}
return changed ? rebuildWithTools(request, tools) : request;
}
private static Object preserveSchemaNumber(Object value) {
if (value instanceof Long number) {
return BigInteger.valueOf(number);
}
if (value instanceof Map<?, ?> map) {
Map<Object, Object> copy = null;
for (Map.Entry<?, ?> entry : map.entrySet()) {
Object normalized = preserveSchemaNumber(entry.getValue());
if (normalized != entry.getValue()) {
if (copy == null) {
copy = new LinkedHashMap<>(map);
}
copy.put(entry.getKey(), normalized);
}
}
return copy != null ? copy : value;
}
if (value instanceof List<?> list) {
List<Object> copy = null;
for (int i = 0; i < list.size(); i++) {
Object normalized = preserveSchemaNumber(list.get(i));
if (normalized != list.get(i)) {
if (copy == null) {
copy = new ArrayList<>(list);
}
copy.set(i, normalized);
}
}
return copy != null ? copy : value;
}
return value;
}
// ==================== reasoning_content patching ====================
/**
@ -308,6 +393,44 @@ final class OpenAiRequestRewriter {
);
}
private static OpenAiApi.ChatCompletionRequest rebuildWithTools(
OpenAiApi.ChatCompletionRequest request, List<OpenAiApi.FunctionTool> tools) {
return new OpenAiApi.ChatCompletionRequest(
request.messages(),
request.model(),
request.store(),
request.metadata(),
request.frequencyPenalty(),
request.logitBias(),
request.logprobs(),
request.topLogprobs(),
request.maxTokens(),
request.maxCompletionTokens(),
request.n(),
request.outputModalities(),
request.audioParameters(),
request.presencePenalty(),
request.responseFormat(),
request.seed(),
request.serviceTier(),
request.stop(),
request.stream(),
request.streamOptions(),
request.temperature(),
request.topP(),
tools,
request.toolChoice(),
request.parallelToolCalls(),
request.user(),
request.reasoningEffort(),
request.webSearchOptions(),
request.verbosity(),
request.promptCacheKey(),
request.safetyIdentifier(),
request.extraBody()
);
}
private static boolean requiresReasoningContentPatch(String modelName) {
ModelFamily family = ModelFamily.detect(modelName);
return family.isThinking();

View File

@ -0,0 +1,103 @@
package vip.mate.llm.chatmodel;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import vip.mate.config.JacksonConfig;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OpenAiToolSchemaNumberPreservationTest {
private static final String MAX_SAFE_INTEGER = "9007199254740991";
@Test
void preservesLongSchemaBoundsAsJsonNumbersWithApplicationMapper() throws Exception {
OpenAiApi.ChatCompletionRequest request = requestWithSchema("""
{
"type": "object",
"properties": {
"requestId": {
"type": "integer",
"minimum": -9007199254740991,
"maximum": 9007199254740991,
"examples": [9007199254740991]
}
}
}
""");
ObjectMapper mapper = applicationMapper();
JsonNode polluted = mapper.readTree(mapper.writeValueAsString(request));
assertTrue(polluted.at("/tools/0/function/parameters/properties/requestId/maximum").isTextual(),
"the regression fixture must reproduce the global Long-to-string pollution");
OpenAiApi.ChatCompletionRequest sanitized =
OpenAiRequestRewriter.preserveToolSchemaNumbers(request);
JsonNode wireJson = mapper.readTree(mapper.writeValueAsString(sanitized));
JsonNode property = wireJson.at("/tools/0/function/parameters/properties/requestId");
assertTrue(property.get("minimum").isIntegralNumber());
assertTrue(property.get("maximum").isIntegralNumber());
assertTrue(property.at("/examples/0").isIntegralNumber());
assertEquals(MAX_SAFE_INTEGER, property.get("maximum").asText());
assertEquals("-" + MAX_SAFE_INTEGER, property.get("minimum").asText());
Map<String, Object> originalProperty = propertyMap(request);
Map<String, Object> sanitizedProperty = propertyMap(sanitized);
assertInstanceOf(Long.class, originalProperty.get("maximum"));
assertInstanceOf(BigInteger.class, sanitizedProperty.get("maximum"));
assertNotSame(request, sanitized);
assertEquals("browser_network_requests", sanitized.tools().getFirst().getFunction().getName());
assertEquals(Boolean.TRUE, sanitized.tools().getFirst().getFunction().getStrict());
}
@Test
void returnsOriginalRequestWhenSchemaContainsNoLongs() {
OpenAiApi.ChatCompletionRequest request = requestWithSchema("""
{"type":"object","properties":{"limit":{"type":"integer","maximum":100}}}
""");
assertSame(request, OpenAiRequestRewriter.preserveToolSchemaNumbers(request));
}
@Test
void returnsOriginalRequestWhenNoToolsArePresent() {
OpenAiApi.ChatCompletionRequest request =
new OpenAiApi.ChatCompletionRequest(List.of(), "deepseek-chat", List.of(), null);
assertSame(request, OpenAiRequestRewriter.preserveToolSchemaNumbers(request));
}
private static OpenAiApi.ChatCompletionRequest requestWithSchema(String schema) {
OpenAiApi.FunctionTool.Function function = new OpenAiApi.FunctionTool.Function(
"Inspect browser network requests", "browser_network_requests", schema);
function.setStrict(true);
OpenAiApi.FunctionTool tool = new OpenAiApi.FunctionTool(function);
return new OpenAiApi.ChatCompletionRequest(
List.of(), "deepseek-chat", List.of(tool), "auto");
}
@SuppressWarnings("unchecked")
private static Map<String, Object> propertyMap(OpenAiApi.ChatCompletionRequest request) {
Map<String, Object> properties = (Map<String, Object>)
request.tools().getFirst().getFunction().getParameters().get("properties");
return (Map<String, Object>) properties.get("requestId");
}
private static ObjectMapper applicationMapper() {
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
new JacksonConfig().longToStringCustomizer().customize(builder);
return builder.build();
}
}