mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(llm): preserve numeric tool schemas (#594)
This commit is contained in:
parent
763fb554da
commit
5e96dade9c
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user