fix(google-genai): preserve declared property order in schemas (#5839)

## Issue
Closes #5838

## Change
`GoogleGenAiToolMapper.convertToGoogleSchema` copied a
`JsonObjectSchema`'s properties into a `HashMap` and never set Gemini's
`propertyOrdering`. `JsonObjectSchema` keeps the declared order (backed
by a `LinkedHashMap`), but the copy lost it, so the generated schema's
field order was effectively arbitrary. Since Gemini uses
`propertyOrdering` to decide field emission order, structured-output
responses and function-call arguments could come back with fields in a
non-deterministic order.

Fix:
- copy properties into a `LinkedHashMap` to keep the declared order;
- set `propertyOrdering` to the property keys for objects with more than
one property.

This matches the official google-genai Python SDK (`_transformers.py`
sets `property_ordering = list(properties.keys())` for objects with >1
property) and the Vertex module in this repo (`SchemaHelper` already
uses a `LinkedHashMap`). The mapper is shared by function-call parameter
schemas and `responseSchema`, so both are fixed. The change is additive:
no public API change (revapi clean).

## Tests
Added
`should_preserve_declared_property_order_and_set_property_ordering` and
`should_set_property_ordering_on_nested_object` to
`GoogleGenAiToolMapperTest`. Both fail on `main` (the first because the
`HashMap` reorders the keys, the second because `propertyOrdering` is
absent) and pass with the fix.

```
mvn -pl langchain4j-google-genai verify -DskipITs   # 133 unit tests, 0 failures; spotless + revapi green
mvn -pl langchain4j-core,langchain4j test           # core 1270, 0 failures; main green
```

Both touched paths are also covered end-to-end by the module's live ITs
that run in CI: `GoogleGenAiAiServiceWithToolsIT` (function calling) and
`GoogleGenAiAiServiceWithJsonSchemaIT` (structured output).

## General checklist
- [x] There are no breaking changes (API, behaviour)
- [x] I have added unit and/or integration tests for my change
- [x] The tests cover both positive and negative cases
- [x] I have manually run all the unit and integration tests in the
module I have added/changed, and they are all green
- [x] I have manually run all the unit and integration tests in the core
and main modules, and they are all green
- [ ] I have added/updated the documentation
- [ ] I have added an example in the examples repo (only for "big"
features)
- [ ] I have added/updated Spring Boot starter(s) (if applicable)
This commit is contained in:
Subhash Polisetti 2026-07-30 01:38:16 -07:00 committed by GitHub
parent e073780617
commit 98a45a1926
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 51 additions and 5 deletions

View File

@ -16,8 +16,9 @@ import dev.langchain4j.model.chat.request.json.JsonNumberSchema;
import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
import dev.langchain4j.model.chat.request.json.JsonSchemaElement;
import dev.langchain4j.model.chat.request.json.JsonStringSchema;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -53,7 +54,7 @@ class GoogleGenAiToolMapper {
}
if (element instanceof JsonObjectSchema objectSchema) {
Map<String, Schema> properties = new HashMap<>();
Map<String, Schema> properties = new LinkedHashMap<>();
if (objectSchema.properties() != null) {
objectSchema
.properties()
@ -61,12 +62,15 @@ class GoogleGenAiToolMapper {
properties.put(key, convertToGoogleSchema(value)));
}
return Schema.builder()
Schema.Builder builder = Schema.builder()
.type(Type.Known.OBJECT)
.properties(properties)
.required(getOrDefault(objectSchema.required(), Collections.emptyList()))
.description(getOrDefault(objectSchema.description(), ""))
.build();
.description(getOrDefault(objectSchema.description(), ""));
if (properties.size() > 1) {
builder.propertyOrdering(new ArrayList<>(properties.keySet()));
}
return builder.build();
}
if (element instanceof JsonStringSchema stringSchema) {

View File

@ -425,6 +425,48 @@ class GoogleGenAiToolMapperTest {
.hasMessageContaining("Unknown schema type");
}
@Test
void should_preserve_declared_property_order_and_set_property_ordering() {
List<String> declaredOrder = List.of("gamma", "alpha", "zeta", "beta", "delta", "epsilon");
JsonObjectSchema.Builder objectBuilder = JsonObjectSchema.builder();
declaredOrder.forEach(name -> objectBuilder.addStringProperty(name));
ToolSpecification spec = ToolSpecification.builder()
.name("test")
.description("test")
.parameters(objectBuilder.build())
.build();
Schema schema =
GoogleGenAiToolMapper.convertToGoogleFunction(spec).parameters().get();
assertThat(schema.properties().get().keySet()).containsExactlyElementsOf(declaredOrder);
assertThat(schema.propertyOrdering().get()).containsExactlyElementsOf(declaredOrder);
}
@Test
void should_set_property_ordering_on_nested_object() {
List<String> nestedOrder = List.of("street", "number", "city", "zip");
JsonObjectSchema.Builder addressBuilder = JsonObjectSchema.builder();
nestedOrder.forEach(name -> addressBuilder.addStringProperty(name));
ToolSpecification spec = ToolSpecification.builder()
.name("test")
.description("test")
.parameters(JsonObjectSchema.builder()
.addProperty("address", addressBuilder.build())
.addStringProperty("name")
.build())
.build();
Schema schema =
GoogleGenAiToolMapper.convertToGoogleFunction(spec).parameters().get();
Schema addressSchema = schema.properties().get().get("address");
assertThat(schema.propertyOrdering().get()).containsExactly("address", "name");
assertThat(addressSchema.propertyOrdering().get()).containsExactlyElementsOf(nestedOrder);
}
@Test
void should_convert_nested_object_schema() {
ToolSpecification spec = ToolSpecification.builder()