From 5e96dade9cb849541b616a06afa6384a3c7d7552 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Sun, 9 Aug 2026 21:31:42 +0800 Subject: [PATCH] fix(llm): preserve numeric tool schemas (#594) --- .../OpenAiCompatibleChatModelBuilder.java | 2 + .../llm/chatmodel/OpenAiRequestRewriter.java | 125 +++++++++++++++++- ...penAiToolSchemaNumberPreservationTest.java | 103 +++++++++++++++ 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiToolSchemaNumberPreservationTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java index 977f26a0..afde9e21 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiCompatibleChatModelBuilder.java @@ -281,6 +281,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder { public org.springframework.http.ResponseEntity chatCompletionEntity( OpenAiApi.ChatCompletionRequest chatRequest, MultiValueMap 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 chatCompletionStream( OpenAiApi.ChatCompletionRequest chatRequest, MultiValueMap additionalHttpHeader) { + chatRequest = OpenAiRequestRewriter.preserveToolSchemaNumbers(chatRequest); chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider); chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider); chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest); diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java index 38e932e6..8fa8c8c6 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiRequestRewriter.java @@ -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; *

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. + * + *

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. + * + *

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 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 parameters = (Map) 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 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 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 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(); diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiToolSchemaNumberPreservationTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiToolSchemaNumberPreservationTest.java new file mode 100644 index 00000000..33cb179b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiToolSchemaNumberPreservationTest.java @@ -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 originalProperty = propertyMap(request); + Map 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 propertyMap(OpenAiApi.ChatCompletionRequest request) { + Map properties = (Map) + request.tools().getFirst().getFunction().getParameters().get("properties"); + return (Map) properties.get("requestId"); + } + + private static ObjectMapper applicationMapper() { + Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder(); + new JacksonConfig().longToStringCustomizer().customize(builder); + return builder.build(); + } +}