mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +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(
|
public org.springframework.http.ResponseEntity<OpenAiApi.ChatCompletion> chatCompletionEntity(
|
||||||
OpenAiApi.ChatCompletionRequest chatRequest,
|
OpenAiApi.ChatCompletionRequest chatRequest,
|
||||||
MultiValueMap<String, String> additionalHttpHeader) {
|
MultiValueMap<String, String> additionalHttpHeader) {
|
||||||
|
chatRequest = OpenAiRequestRewriter.preserveToolSchemaNumbers(chatRequest);
|
||||||
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
|
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
|
||||||
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
|
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
|
||||||
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
|
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
|
||||||
@ -302,6 +303,7 @@ public class OpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
|
|||||||
public Flux<OpenAiApi.ChatCompletionChunk> chatCompletionStream(
|
public Flux<OpenAiApi.ChatCompletionChunk> chatCompletionStream(
|
||||||
OpenAiApi.ChatCompletionRequest chatRequest,
|
OpenAiApi.ChatCompletionRequest chatRequest,
|
||||||
MultiValueMap<String, String> additionalHttpHeader) {
|
MultiValueMap<String, String> additionalHttpHeader) {
|
||||||
|
chatRequest = OpenAiRequestRewriter.preserveToolSchemaNumbers(chatRequest);
|
||||||
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
|
chatRequest = OpenAiRequestRewriter.sanitizeReasoningEffortForProvider(chatRequest, provider);
|
||||||
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
|
chatRequest = OpenAiRequestRewriter.patchReasoningContent(chatRequest, provider);
|
||||||
chatRequest = OpenAiRequestRewriter.stripReasoningEffortIfIncompatible(chatRequest);
|
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.ModelFamily;
|
||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -22,13 +23,97 @@ import java.util.Map;
|
|||||||
* <p>The rewrites exist because OpenAI-compatible providers diverge in ways
|
* <p>The rewrites exist because OpenAI-compatible providers diverge in ways
|
||||||
* Spring AI's {@code OpenAiChatOptions} cannot express — reasoning-content
|
* Spring AI's {@code OpenAiChatOptions} cannot express — reasoning-content
|
||||||
* replay contracts, reasoning-effort acceptance, strict tool-choice validation,
|
* 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
|
@Slf4j
|
||||||
final class OpenAiRequestRewriter {
|
final class OpenAiRequestRewriter {
|
||||||
|
|
||||||
private 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 ====================
|
// ==================== 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) {
|
private static boolean requiresReasoningContentPatch(String modelName) {
|
||||||
ModelFamily family = ModelFamily.detect(modelName);
|
ModelFamily family = ModelFamily.detect(modelName);
|
||||||
return family.isThinking();
|
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