This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 62c65b0e9 [integrations][java][python] Skip native structured output
for a Map, JsonNode or dict field (#1265)
62c65b0e9 is described below
commit 62c65b0e928668579f0fd7ae06581646f71d3cca
Author: Weiqing Yang <[email protected]>
AuthorDate: Sun Oct 18 23:24:55 2026 -0700
[integrations][java][python] Skip native structured output for a Map,
JsonNode or dict field (#1265)
Generated-by: GitHub Copilot CLI 1.0.98 (Claude Opus 5.5)
---
.../development/calling-llms/structured-output.md | 15 +-
.../anthropic/AnthropicChatModelConnection.java | 44 ++++--
.../AnthropicChatModelConnectionTest.java | 40 ++++-
.../chatmodels/common/PojoJsonSchemaGenerator.java | 44 +++++-
.../common/PojoJsonSchemaGeneratorTest.java | 167 +++++++++++++++++++++
.../gemini/GeminiChatModelConnection.java | 49 ++++--
.../gemini/GeminiChatModelConnectionTest.java | 32 +++-
.../openai/AzureOpenAIChatModelConnection.java | 20 ++-
.../openai/OpenAIChatCompletionsUtils.java | 24 ++-
.../openai/OpenAICompletionsConnection.java | 15 +-
.../openai/AzureOpenAIChatModelConnectionTest.java | 39 ++++-
.../openai/OpenAICompletionsConnectionTest.java | 34 ++++-
.../openai/VLLMChatModelConnectionTest.java | 13 ++
.../api/agents/tests/test_output_schema_render.py | 132 +++++++++++++++-
python/flink_agents/api/agents/types.py | 51 +++++++
.../chat_models/anthropic/anthropic_chat_model.py | 31 ++--
.../tests/test_anthropic_response_parsing.py | 51 +++++--
.../chat_models/azure/azure_openai_chat_model.py | 25 ++-
.../test_azure_openai_native_structured_output.py | 41 +++--
.../chat_models/openai/openai_chat_model.py | 18 ++-
.../tests/test_openai_native_structured_output.py | 41 +++--
.../chat_models/vllm/tests/test_vllm_chat_model.py | 27 ++++
.../chat_models/vllm/vllm_chat_model.py | 22 ++-
23 files changed, 867 insertions(+), 108 deletions(-)
diff --git a/docs/content/docs/development/calling-llms/structured-output.md
b/docs/content/docs/development/calling-llms/structured-output.md
index d66b30df4..d566fb910 100644
--- a/docs/content/docs/development/calling-llms/structured-output.md
+++ b/docs/content/docs/development/calling-llms/structured-output.md
@@ -209,13 +209,13 @@ Native path: ChatRequestEvent → model call (tool loop,
no schema)
| Provider | Languages | Recommended models (`AUTO` goes native) | Blocked
(besides a non-typed-class schema) |
|---|---|---|---|
-| OpenAI | Java, Python | `gpt-4o-mini`, `gpt-4o-search-preview`, `gpt-4.1`,
`gpt-5` and `o3`, `o4-mini` families (names that start with these), plus
`gpt-4o`, `gpt-4o-2024-08-06`, `gpt-4o-2024-11-20`, `o1`, `o1-2024-12-17`.
Audio, realtime, TTS and transcribe variants are excluded. | |
+| OpenAI | Java, Python | `gpt-4o-mini`, `gpt-4o-search-preview`, `gpt-4.1`,
`gpt-5` and `o3`, `o4-mini` families (names that start with these), plus
`gpt-4o`, `gpt-4o-2024-08-06`, `gpt-4o-2024-11-20`, `o1`, `o1-2024-12-17`.
Audio, realtime, TTS and transcribe variants are excluded. | The schema has an
open-keyed field |
| OpenAI Responses | Java | none | Always. Use the prompt path. |
-| Azure OpenAI | Java, Python | The model named by `model_of_azure_deployment`
is one of `gpt-5.1`, `gpt-5.1-chat`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o3`,
`o3-mini`, `o4-mini`, `o1`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4.1-mini`,
`gpt-4.1-nano`. | `api_version` older than `2024-08-01`, or not a date |
-| vLLM | Java, Python | Any model | Same as OpenAI |
-| Anthropic | Java, Python | `claude-opus-4-6`, `-4-7`, `-4-8`, `-5`;
`claude-sonnet-4-6`, `-5`; `claude-fable-5`; `claude-mythos-5`;
`claude-mythos-preview`; and the `claude-opus-4-5`, `claude-sonnet-4-5`,
`claude-haiku-4-5` aliases and their dated versions | You pass `output_config`
in `additional_kwargs` |
+| Azure OpenAI | Java, Python | The model named by `model_of_azure_deployment`
is one of `gpt-5.1`, `gpt-5.1-chat`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `o3`,
`o3-mini`, `o4-mini`, `o1`, `gpt-4o-mini`, `gpt-4.1`, `gpt-4.1-mini`,
`gpt-4.1-nano`. | `api_version` older than `2024-08-01`, or not a date. The
schema has an open-keyed field. |
+| vLLM | Java, Python | Any model | Java only: the schema has an open-keyed
field |
+| Anthropic | Java, Python | `claude-opus-4-6`, `-4-7`, `-4-8`, `-5`;
`claude-sonnet-4-6`, `-5`; `claude-fable-5`; `claude-mythos-5`;
`claude-mythos-preview`; and the `claude-opus-4-5`, `claude-sonnet-4-5`,
`claude-haiku-4-5` aliases and their dated versions | You pass `output_config`
in `additional_kwargs`. The schema has an open-keyed field. |
| Amazon Bedrock | Java | A fixed list of Converse models (a regional or
global inference-profile prefix is ignored). ARNs are never recommended. | |
-| Gemini | Java | `gemini-*` text models. Image, TTS, audio, live, transcribe,
embedding and omni variants are excluded. | The request binds tools |
+| Gemini | Java | `gemini-*` text models. Image, TTS, audio, live, transcribe,
embedding and omni variants are excluded. | The request binds tools. The schema
has a `JsonNode` field. |
| Ollama | Java, Python | Any model | |
| watsonx | Java, Python | Any model | |
| DashScope | Python | `qwen3.7-max`, `qwen3.7-max-preview`,
`qwen3.7-max-2026-05-17`, `qwen3.7-max-2026-05-20` | |
@@ -226,6 +226,7 @@ Notes:
- **Gemini** blocks native output when the request binds tools. The conversion
call is tool-free, so this only affects direct connection calls. A newer model
that does not support the schema field fails with `400 INVALID_ARGUMENT`.
Constraints such as `pattern` and `minLength` are ignored by the server.
- **Ollama** needs a server at v0.5.0 or later. Ollama Cloud and the MLX
runner accept the schema but do not enforce it.
- **Bedrock** rejects recursive schemas, and map values are untyped.
+- **An open-keyed field blocks native output on OpenAI, Azure OpenAI,
Anthropic and Java vLLM.** That is a field whose keys are not fixed in advance,
at any depth: a Java `Map` or Jackson `JsonNode` field, or a Python `dict`
field or a Pydantic model with `extra="allow"`. These providers need every
object in the schema to list its keys. Gemini blocks only a `JsonNode` field,
because it can type a `Map`'s values. Python vLLM keeps the field native,
because vLLM's guided decoding enforces [...]
- **A native parameter you set yourself conflicts.** Do not put
`response_format` (OpenAI-compatible providers) or `output_config` (Anthropic)
in `additional_kwargs` or model parameters when you use an output schema. Azure
OpenAI, watsonx and DashScope raise an error when the schema would overwrite
your value. Anthropic treats the request as blocked.
- **Cross-language setups never go native.** A chat model from the other
language, reached through a [cross-language resource]({{< ref
"docs/development/calling-llms/chat-models#using-cross-language-providers"
>}}), cannot carry a schema across the bridge. `AUTO` and `PROMPT` use the
prompt path, and `NATIVE` raises. To use a provider natively, declare the agent
in that provider's language.
@@ -314,12 +315,12 @@ public enum Sentiment {
### Java POJOs
- **Descriptions and required fields.** `@JsonPropertyDescription` and
`@JsonClassDescription` are sent in the native schema, and every field is
required (an `Optional` field is required but nullable on OpenAI, Azure OpenAI
and Anthropic). The ReAct Agent's *prompt text* is rendered by a different
generator, which has no descriptions, and lists a field as required only when
it has `@JsonProperty(required = true)`. The prompt path therefore describes
the schema less fully than the native path.
-- **`Map` fields.** On OpenAI, Azure OpenAI, Anthropic and vLLM, a
`Map<String, V>` field becomes an empty object schema that allows no keys, so
the model can only return `{}`. Avoid `Map` fields with these providers, or use
a nested class. Gemini, Ollama and watsonx type the map values.
+- **`Map` and `JsonNode` fields.** On OpenAI, Azure OpenAI, Anthropic and
vLLM, a schema with either field uses the prompt path under `AUTO` and fails
under `NATIVE`. The native schema would describe the field as an empty object
that allows no keys, so the model could only return `{}`. Gemini keeps a `Map`
native and types its values, but blocks a `JsonNode` field the same way. Ollama
and watsonx type the map values. To stay native everywhere, use a nested class.
- **Recursive types.** The ReAct Agent rejects a self-referential POJO at
construction time. Bedrock also rejects a recursive schema.
### Python Models
-The schema comes from Pydantic's `model_json_schema()`, so field descriptions
and defaults are included. A model that Pydantic cannot express raises a
`TypeError` naming the schema when it is rendered.
+The schema comes from Pydantic's `model_json_schema()`, so field descriptions
and defaults are included. A model that Pydantic cannot express raises a
`TypeError` naming the schema when it is rendered. A `dict` field, or a model
with `extra="allow"`, blocks native output on OpenAI, Azure OpenAI and
Anthropic, as a `Map` does in Java. Use a nested model to stay native there.
vLLM keeps the field native.
## Next Steps
diff --git
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
index a9498d650..f7df5b32f 100644
---
a/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
+++
b/integrations/chat-models/anthropic/src/main/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnection.java
@@ -28,6 +28,7 @@ import com.anthropic.models.messages.Model;
import com.anthropic.models.messages.OutputConfig;
import com.anthropic.models.messages.TextBlockParam;
import com.anthropic.models.messages.Tool;
+import com.fasterxml.jackson.databind.JsonNode;
import com.github.victools.jsonschema.generator.Option;
import org.apache.flink.agents.api.chat.messages.ChatMessage;
import org.apache.flink.agents.api.chat.messages.ChatResult;
@@ -202,11 +203,13 @@ public class AnthropicChatModelConnection extends
BaseChatModelConnection {
* Whether a request built from these inputs would carry a derived {@code
output_config}, the
* effective model's capability aside.
*
- * <p>Two conditions. Only a POJO {@link Class} has a native translation
here; a {@code
- * RowTypeInfo} wrapped in {@code OutputSchema}, or any other form, has
none. And a
- * caller-supplied {@code output_config} wins: it is the caller being
explicit about the exact
- * parameter the native branch writes, so the schema falls back to prompt
engineering rather
- * than the two competing on one request.
+ * <p>Two conditions. Only a POJO {@link Class} has a native translation
here, and only one
+ * whose schema reaches no {@code Map} or {@code JsonNode}: the native
schema closes every
+ * object, so the model could return only {@code {}} for one. A {@code
RowTypeInfo} wrapped in
+ * {@code OutputSchema}, or any other form, has none. And a
caller-supplied {@code
+ * output_config} wins: it is the caller being explicit about the exact
parameter the native
+ * branch writes, so the schema falls back to prompt engineering rather
than the two competing
+ * on one request.
*
* <p>The tools are not read; a bound tool does not stop this connection
sending a schema.
*
@@ -216,14 +219,16 @@ public class AnthropicChatModelConnection extends
BaseChatModelConnection {
* additional_kwargs}. These must be the parameters as they arrived:
{@code buildRequest}
* removes that key from its own copy before the native branch runs,
so the stripped copy
* would report every caller's {@code output_config} absent
- * @return true if {@code outputSchema} is a POJO {@link Class} and the
caller supplied no
- * {@code output_config} of its own
+ * @return true if {@code outputSchema} is a POJO {@link Class} that
reaches no {@code Map} or
+ * {@code JsonNode} and the caller supplied no {@code output_config}
of its own
*/
private boolean canApplyNativeStructuredOutput(
Object outputSchema,
List<org.apache.flink.agents.api.tools.Tool> tools,
Map<String, Object> modelParams) {
- return outputSchema instanceof Class &&
!carriesCallerOutputConfig(modelParams);
+ return outputSchema instanceof Class
+ && !carriesCallerOutputConfig(modelParams)
+ && canDescribeNatively((Class<?>) outputSchema);
}
/**
@@ -405,6 +410,26 @@ public class AnthropicChatModelConnection extends
BaseChatModelConnection {
return override instanceof Number ? override : topLevel;
}
+ // The native schema closes every object it describes, as structured
outputs require. A Map's
+ // keys are not known in advance, and a Jackson JsonNode holds any JSON
value, which the
+ // generator describes as an object. Closed, either admits only {}, so a
model held to the
+ // schema returns that and the data is lost.
+ private static final Option[] NATIVE_SCHEMA_OPTIONS = {
+ Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT
+ };
+
+ private static final List<Class<?>> TYPES_WITHOUT_NATIVE_FORM =
+ List.of(Map.class, JsonNode.class);
+
+ /**
+ * Whether the native {@code output_config} can state {@code
schemaClass}'s contract, which it
+ * cannot once the schema reaches a {@code Map} or a {@code JsonNode}.
+ */
+ private static boolean canDescribeNatively(Class<?> schemaClass) {
+ return !PojoJsonSchemaGenerator.reachesAny(
+ schemaClass, TYPES_WITHOUT_NATIVE_FORM, NATIVE_SCHEMA_OPTIONS);
+ }
+
/**
* Derives the native {@code output_config} for a POJO output schema class.
*
@@ -415,8 +440,7 @@ public class AnthropicChatModelConnection extends
BaseChatModelConnection {
*/
private static OutputConfig toNativeOutputConfig(Class<?> schemaClass) {
Map<String, JsonValue> schema = new LinkedHashMap<>();
- PojoJsonSchemaGenerator.generateAllRequired(
- schemaClass,
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT)
+ PojoJsonSchemaGenerator.generateAllRequired(schemaClass,
NATIVE_SCHEMA_OPTIONS)
.fields()
.forEachRemaining(
member ->
diff --git
a/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
index 6ca83790e..46d10c63a 100644
---
a/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
+++
b/integrations/chat-models/anthropic/src/test/java/org/apache/flink/agents/integrations/chatmodels/anthropic/AnthropicChatModelConnectionTest.java
@@ -384,6 +384,18 @@ class AnthropicChatModelConnectionTest {
public Answer answer;
}
+ /** Output schema fixture with a {@code Map} field, whose keys are not
known in advance. */
+ public static class Tally {
+ public String label;
+ public Map<String, Integer> counts;
+ }
+
+ /** Output schema fixture with a {@code JsonNode} field, which holds any
JSON value. */
+ public static class Note {
+ public String title;
+ public JsonNode body;
+ }
+
private static Map<String, Object> paramsWithModel(String model, Object
jsonPrefill) {
Map<String, Object> params = params(jsonPrefill);
params.put("model", model);
@@ -497,6 +509,30 @@ class AnthropicChatModelConnectionTest {
assertThat(build(CAPABLE_MODEL, "not-a-class",
null).params.outputConfig()).isEmpty();
}
+ @Test
+ @DisplayName("a schema reaching a Map or a JsonNode keeps the prompt
fallback")
+ void testOpenObjectSchemaKeepsFallback() {
+ // The native schema closes every object, so it would admit only {}
for the map or the
+ // tree and the model would return that. The prompt path keeps the
field's contents, and
+ // the json_prefill it relies on with it.
+ for (Class<?> schema : List.of(Tally.class, Note.class)) {
+ assertThat(build(CAPABLE_MODEL, schema,
null).params.outputConfig())
+ .as(schema.getSimpleName())
+ .isEmpty();
+ assertThat(build(INCAPABLE_MODEL, schema, true).jsonPrefillApplied)
+ .as(schema.getSimpleName())
+ .isTrue();
+ assertThat(
+ connection()
+ .supportsNativeStructuredOutput(
+ schema,
+ List.of(),
+ paramsWithModel(CAPABLE_MODEL,
null)))
+ .as(schema.getSimpleName())
+ .isEqualTo(NativeStructuredOutputSupport.INFEASIBLE);
+ }
+ }
+
@Test
@DisplayName("the native path adds no anthropic-beta header")
void testNativePathSendsNoBetaHeader() {
@@ -1171,7 +1207,9 @@ class AnthropicChatModelConnectionTest {
AnthropicChatModelConnection connection =
new AnthropicChatModelConnection(descriptor(configured),
NOOP);
- for (Object schema : Arrays.asList(Answer.class, "row<name
STRING>", null)) {
+ for (Object schema :
+ Arrays.asList(
+ Answer.class, Tally.class, Note.class, "row<name
STRING>", null)) {
for (boolean callerConfig : List.of(false, true)) {
for (List<Tool> tools :
Arrays.asList(List.<Tool>of(), List.<Tool>of(new
StubTool()), null)) {
diff --git
a/integrations/chat-models/common/src/main/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGenerator.java
b/integrations/chat-models/common/src/main/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGenerator.java
index b849e05a7..f3e7d4db9 100644
---
a/integrations/chat-models/common/src/main/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGenerator.java
+++
b/integrations/chat-models/common/src/main/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGenerator.java
@@ -36,9 +36,11 @@ import
com.github.victools.jsonschema.generator.impl.PropertySortUtils;
import com.github.victools.jsonschema.module.jackson.JacksonModule;
import com.github.victools.jsonschema.module.jackson.JacksonOption;
+import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
/** Derives the JSON Schema that chat model connections send as a native
output schema. */
@@ -105,8 +107,48 @@ public final class PojoJsonSchemaGenerator {
return generate(type, field -> true, options);
}
+ /**
+ * Reports whether the schema generated for {@code type} reaches a type
that is an instance of
+ * one of {@code targets}.
+ *
+ * <p>The check runs the same generation as {@link #generate}, so it
reaches exactly the types
+ * that schema describes: {@code type} itself, the type of every field,
the element and value
+ * types the options describe, and every subtype Jackson resolves, but not
a field Jackson
+ * ignores. A caller uses it to find a type its provider's schema cannot
state before sending a
+ * schema that misstates it.
+ *
+ * @param type the class a response is deserialized into
+ * @param targets the types to look for; a subtype or implementation of
one counts
+ * @param options the options the caller generates its schema with. They
decide what the schema
+ * describes: a map's value type, for example, is reached only under
{@link
+ * Option#MAP_VALUES_AS_ADDITIONAL_PROPERTIES}
+ * @return true if the schema reaches an instance of one of {@code targets}
+ */
+ public static boolean reachesAny(
+ Class<?> type, Collection<Class<?>> targets, Option... options) {
+ AtomicBoolean reached = new AtomicBoolean();
+ SchemaGeneratorConfigBuilder builder = configure(field -> true,
options);
+ // This provider is offered each type the schema describes. It returns
null, so generation
+ // proceeds as configured.
+ builder.forTypesInGeneral()
+ .withCustomDefinitionProvider(
+ (javaType, context) -> {
+ if
(targets.stream().anyMatch(javaType::isInstanceOf)) {
+ reached.set(true);
+ }
+ return null;
+ });
+ new SchemaGenerator(builder.build()).generateSchema(type);
+ return reached.get();
+ }
+
private static ObjectNode generate(
Class<?> type, Predicate<FieldScope> requiredCheck, Option...
options) {
+ return new SchemaGenerator(configure(requiredCheck,
options).build()).generateSchema(type);
+ }
+
+ private static SchemaGeneratorConfigBuilder configure(
+ Predicate<FieldScope> requiredCheck, Option... options) {
SchemaGeneratorConfigBuilder builder =
new SchemaGeneratorConfigBuilder(
SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON);
@@ -122,7 +164,7 @@ public final class PojoJsonSchemaGenerator {
builder.forTypesInGeneral()
.withPropertySorter(PropertySortUtils.SORT_PROPERTIES_FIELDS_BEFORE_METHODS);
builder.forFields().withRequiredCheck(requiredCheck);
- return new SchemaGenerator(builder.build()).generateSchema(type);
+ return builder;
}
private static CustomDefinition enumDefinition(
diff --git
a/integrations/chat-models/common/src/test/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGeneratorTest.java
b/integrations/chat-models/common/src/test/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGeneratorTest.java
index 9bcace4a5..7b12ae6be 100644
---
a/integrations/chat-models/common/src/test/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGeneratorTest.java
+++
b/integrations/chat-models/common/src/test/java/org/apache/flink/agents/integrations/chatmodels/common/PojoJsonSchemaGeneratorTest.java
@@ -20,9 +20,12 @@ package
org.apache.flink.agents.integrations.chatmodels.common;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSubTypes;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.victools.jsonschema.generator.Option;
import org.junit.jupiter.api.DisplayName;
@@ -30,6 +33,7 @@ import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -105,6 +109,89 @@ class PojoJsonSchemaGeneratorTest {
}
}
+ /** Output schema fixture reaching a map only through a nested class. */
+ public static class Wrapper {
+ public Report report;
+ }
+
+ /** Output schema fixture reaching maps as list elements. */
+ public static class Rows {
+ public List<Map<String, String>> rows;
+ }
+
+ /** Output schema fixture reaching maps as array elements. */
+ public static class Cells {
+ public Map<String, String>[] cells;
+ }
+
+ /** Output schema fixture reaching a concrete map implementation inside an
Optional. */
+ public static class Lookup {
+ public Optional<HashMap<String, Profile>> byName;
+ }
+
+ /** Output schema fixture reaching a map only through a subtype Jackson
resolves. */
+ public static class Zoo {
+ public Animal animal;
+ }
+
+ /** Polymorphic base type whose subtypes Jackson names in the payload. */
+ @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
+ @JsonSubTypes({
+ @JsonSubTypes.Type(value = Parrot.class, name = "parrot"),
+ @JsonSubTypes.Type(value = Snake.class, name = "snake")
+ })
+ public abstract static class Animal {
+ public String name;
+ }
+
+ /** Subtype carrying a map. */
+ public static class Parrot extends Animal {
+ public Map<String, Integer> phrases;
+ }
+
+ /** Subtype carrying no map. */
+ public static class Snake extends Animal {
+ public int length;
+ }
+
+ /** Output schema fixture holding any JSON value. */
+ public static class AnyTree {
+ public JsonNode value;
+ }
+
+ /** Output schema fixture holding a JSON array. */
+ public static class ArrayTree {
+ public ArrayNode values;
+ }
+
+ /** Output schema fixture holding JSON values as list elements. */
+ public static class TreeList {
+ public List<JsonNode> values;
+ }
+
+ /** Output schema fixture whose only map is a field Jackson ignores. */
+ public static class IgnoredMap {
+ @JsonIgnore public Map<String, String> internal;
+
+ public String name;
+ }
+
+ /** Output schema fixture referring back to itself. */
+ public static class Chain {
+ public String value;
+ public Chain next;
+ }
+
+ /** Output schema fixture with a field named like the keyword that types a
map's values. */
+ public static class Keyword {
+ public String additionalProperties;
+ }
+
+ /** Output schema fixture whose only JSON tree is a map's value type. */
+ public static class Catalog {
+ public Map<String, JsonNode> entries;
+ }
+
/**
* Output schema fixture for enums the Jackson module's flattened enums
misstate: one maps only
* some of its constants through {@code @JsonProperty}, and one maps its
constants to numbers
@@ -402,6 +489,86 @@ class PojoJsonSchemaGeneratorTest {
assertThat(withoutOption.has("additionalProperties")).isFalse();
}
+ @Test
+ @DisplayName("A map or JSON tree is reached at any depth the schema
describes")
+ void reachesMapsAndTreesAtAnyDepth() {
+ for (Class<?> type :
+ List.of(
+ Report.class,
+ Wrapper.class,
+ Rows.class,
+ Cells.class,
+ Lookup.class,
+ Zoo.class,
+ HashMap.class,
+ AnyTree.class,
+ ArrayTree.class,
+ TreeList.class)) {
+ assertThat(
+ PojoJsonSchemaGenerator.reachesAny(
+ type,
+ List.of(Map.class, JsonNode.class),
+
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT))
+ .as(type.getSimpleName())
+ .isTrue();
+ }
+ }
+
+ @Test
+ @DisplayName("A type the schema does not describe is not reached")
+ void doesNotReachTypesTheSchemaLeavesOut() {
+ // IgnoredMap declares a map that Jackson never reads, so the schema
leaves it out, and
+ // Keyword only names a field after the keyword a map's value schema
is written under.
+ for (Class<?> type :
+ List.of(
+ Profile.class,
+ Ticket.class,
+ IgnoredMap.class,
+ Chain.class,
+ Keyword.class,
+ Snake.class)) {
+ assertThat(
+ PojoJsonSchemaGenerator.reachesAny(
+ type,
+ List.of(Map.class, JsonNode.class),
+
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT))
+ .as(type.getSimpleName())
+ .isFalse();
+ }
+ }
+
+ @Test
+ @DisplayName("A map's value type is reached only under the option that
describes it")
+ void reachesMapValuesOnlyUnderTheirOption() {
+ List<Class<?>> trees = List.of(JsonNode.class);
+
+ assertThat(PojoJsonSchemaGenerator.reachesAny(Catalog.class,
trees)).isFalse();
+ assertThat(
+ PojoJsonSchemaGenerator.reachesAny(
+ Catalog.class, trees,
Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES))
+ .isTrue();
+ }
+
+ /** Output schema fixture holding a map beside enums the generator defines
itself. */
+ public static class TaggedOrder {
+ public PartlyMapped status;
+ public Sign sign;
+ public Map<String, String> tags;
+ }
+
+ @Test
+ @DisplayName("An enum the generator defines itself does not hide a map
beside it")
+ void reachesMapBesideEnumDefinition() {
+ // The check is offered only the types no earlier definition provider
claims. The enum
+ // definition claims each enum, so the fields beside an enum must
still be reached.
+ List<Class<?>> openKeyed = List.of(Map.class, JsonNode.class);
+ Option strict = Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT;
+
+ assertThat(PojoJsonSchemaGenerator.reachesAny(Order.class, openKeyed,
strict)).isFalse();
+ assertThat(PojoJsonSchemaGenerator.reachesAny(TaggedOrder.class,
openKeyed, strict))
+ .isTrue();
+ }
+
private static List<String> fieldNames(JsonNode node) {
List<String> names = new ArrayList<>();
node.fieldNames().forEachRemaining(names::add);
diff --git
a/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java
b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java
index b2471ee62..49c3e2178 100644
---
a/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java
+++
b/integrations/chat-models/gemini/src/main/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnection.java
@@ -255,12 +255,13 @@ public class GeminiChatModelConnection extends
BaseChatModelConnection {
* Whether a config built from these inputs would carry a native {@code
responseJsonSchema}, the
* effective model's capability aside.
*
- * <p>Two conditions. Only a POJO {@link Class} has a native translation
here; a {@code
- * RowTypeInfo} wrapped in {@code OutputSchema}, or any other form, has
none. And the request
- * must bind no tools: outside a documented preview, Gemini answers a
request combining function
- * declarations with a JSON response mime type with {@code 400
INVALID_ARGUMENT}, so a
- * tool-carrying request keeps its tools and drops the schema. This is the
only connection whose
- * answer the tools can move.
+ * <p>Two conditions. Only a POJO {@link Class} has a native translation
here, and only one
+ * whose schema reaches no {@code JsonNode}: the native schema closes the
object it describes
+ * one as, so the model could return only {@code {}} for it. A {@code
RowTypeInfo} wrapped in
+ * {@code OutputSchema}, or any other form, has none. And the request must
bind no tools:
+ * outside a documented preview, Gemini answers a request combining
function declarations with a
+ * JSON response mime type with {@code 400 INVALID_ARGUMENT}, so a
tool-carrying request keeps
+ * its tools and drops the schema. This is the only connection whose
answer the tools can move.
*
* <p>No parameter is read today. It is nonetheless asked with the
caller's parameters rather
* than a copy the request builder has consumed, so that a conjunct added
here later reads the
@@ -269,13 +270,16 @@ public class GeminiChatModelConnection extends
BaseChatModelConnection {
* @param outputSchema the schema the request would carry, or null for an
unconstrained request
* @param tools the tools the request would bind; null and empty alike
mean none
* @param arguments not read
- * @return true if {@code outputSchema} is a POJO {@link Class} and no
tools would be bound
+ * @return true if {@code outputSchema} is a POJO {@link Class} that
reaches no {@code JsonNode}
+ * and no tools would be bound
*/
private boolean canApplyNativeStructuredOutput(
Object outputSchema,
List<org.apache.flink.agents.api.tools.Tool> tools,
Map<String, Object> arguments) {
- return outputSchema instanceof Class && (tools == null ||
tools.isEmpty());
+ return outputSchema instanceof Class
+ && (tools == null || tools.isEmpty())
+ && canDescribeNatively((Class<?>) outputSchema);
}
@Override
@@ -449,15 +453,34 @@ public class GeminiChatModelConnection extends
BaseChatModelConnection {
// Enabling it instead produces a $defs entry referencing another $defs
entry, a shape no
// published Gemini example demonstrates.
private static ObjectNode toNativeJsonSchema(Class<?> schemaClass) {
- ObjectNode schema =
- PojoJsonSchemaGenerator.generate(
- schemaClass,
- Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES,
- Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT);
+ ObjectNode schema = PojoJsonSchemaGenerator.generate(schemaClass,
NATIVE_SCHEMA_OPTIONS);
stripRefSiblings(schema);
return schema;
}
+ // The options listed above toNativeJsonSchema. canDescribeNatively
generates under the same
+ // ones, because whether a type is reached depends on them: a Map's value
type is reached only
+ // under MAP_VALUES_AS_ADDITIONAL_PROPERTIES.
+ private static final Option[] NATIVE_SCHEMA_OPTIONS = {
+ Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES,
+ Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT
+ };
+
+ // A Jackson JsonNode holds any JSON value, but the generator describes it
as an object, which
+ // FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT then closes. The schema
admits only {} for it, so
+ // a model held to the schema returns that and the data is lost. A Map
keeps its value schema
+ // under MAP_VALUES_AS_ADDITIONAL_PROPERTIES, so it is not listed.
+ private static final List<Class<?>> TYPES_WITHOUT_NATIVE_FORM =
List.of(JsonNode.class);
+
+ /**
+ * Whether the native {@code responseJsonSchema} can state {@code
schemaClass}'s contract, which
+ * it cannot once the schema reaches a {@code JsonNode}.
+ */
+ private static boolean canDescribeNatively(Class<?> schemaClass) {
+ return !PojoJsonSchemaGenerator.reachesAny(
+ schemaClass, TYPES_WITHOUT_NATIVE_FORM, NATIVE_SCHEMA_OPTIONS);
+ }
+
// Gemini states that a sub-schema setting $ref may set no other property
except those starting
// with a $, so every non-$ sibling is removed wherever a $ref appears.
The generator writes a
// referencing field's own keywords beside the reference, and two shapes
are measured to produce
diff --git
a/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java
b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java
index 1bacb04b4..dc36a1ff1 100644
---
a/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java
+++
b/integrations/chat-models/gemini/src/test/java/org/apache/flink/agents/integrations/chatmodels/gemini/GeminiChatModelConnectionTest.java
@@ -133,6 +133,17 @@ class GeminiChatModelConnectionTest {
public int total;
}
+ /** Output schema fixture with a {@code JsonNode} field, which holds any
JSON value. */
+ public static class Note {
+ public String title;
+ public JsonNode body;
+ }
+
+ /** Output schema fixture whose map values are {@code JsonNode}s. */
+ public static class Catalog {
+ public Map<String, JsonNode> entries;
+ }
+
/** Nested type reused by two described fields of {@link Addresses}. */
public static class Address {
public String street;
@@ -775,6 +786,24 @@ class GeminiChatModelConnectionTest {
assertThat(config.responseMimeType()).isEmpty();
}
+ @Test
+ @DisplayName("A schema reaching a JsonNode is skipped, directly or as a
map's values")
+ void nativeSchemaSkippedForJsonNode() {
+ // The generator describes a JsonNode as an object and the native
schema closes it, so it
+ // would admit only {} and the model would return that. A map with
typed values keeps its
+ // value schema and stays native, as the Report fixture shows.
+ for (Class<?> schema : List.of(Note.class, Catalog.class)) {
+ GenerateContentConfig config =
+ connection().buildConfig(userMessage(), null, params(),
schema);
+
+
assertThat(config.responseJsonSchema()).as(schema.getSimpleName()).isEmpty();
+
assertThat(config.responseMimeType()).as(schema.getSimpleName()).isEmpty();
+ assertThat(connection().supportsNativeStructuredOutput(schema,
List.of(), params()))
+ .as(schema.getSimpleName())
+ .isEqualTo(NativeStructuredOutputSupport.INFEASIBLE);
+ }
+ }
+
@Test
@DisplayName("The derived schema lists enum constants by the values
Jackson deserializes")
void derivedSchemaListsEnumsByTheirJacksonWireValues() throws Exception {
@@ -1017,7 +1046,8 @@ class GeminiChatModelConnectionTest {
// capable throughout, so the schema form and the bound tools are what
move.
GeminiChatModelConnection connection = connection();
- for (Object schema : Arrays.asList(Report.class, "row<name STRING>",
null)) {
+ for (Object schema :
+ Arrays.asList(Report.class, Note.class, Catalog.class,
"row<name STRING>", null)) {
for (List<Tool> tools :
Arrays.asList(List.<Tool>of(), List.<Tool>of(new
SchemaOnlyTool()), null)) {
NativeStructuredOutputSupport answer =
diff --git
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java
index 9c69cc5ec..b30eccefd 100644
---
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java
+++
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnection.java
@@ -281,10 +281,11 @@ public class AzureOpenAIChatModelConnection extends
BaseChatModelConnection {
* Whether {@code outputSchema} is a form this connection could translate
into a native {@code
* response_format}, the effective model's capability aside.
*
- * <p>Two conditions. Only a POJO {@link Class} has a native translation
here; a {@code
- * RowTypeInfo} wrapped in {@code OutputSchema}, or any other form, has
none. And the configured
- * api-version has to reach the structured-output floor, which is a
property of this connection
- * rather than of the request.
+ * <p>Two conditions. Only a POJO {@link Class} has a native translation
here, and only one
+ * whose schema reaches no {@code Map} or {@code JsonNode}: strict mode
closes every object, so
+ * the model could return only {@code {}} for one. A {@code RowTypeInfo}
wrapped in {@code
+ * OutputSchema}, or any other form, has none. And the configured
api-version has to reach the
+ * structured-output floor, which is a property of this connection rather
than of the request.
*
* <p>A {@code true} answer is about those two conditions alone, and is
not a promise that a
* request gets built. A caller-supplied {@code response_format} in {@code
additional_kwargs} is
@@ -300,13 +301,16 @@ public class AzureOpenAIChatModelConnection extends
BaseChatModelConnection {
* @param outputSchema the schema the request would carry, or null for an
unconstrained request
* @param tools not read; bound tools do not stop this connection sending
a native schema
* @param modelParams not read
- * @return true if {@code outputSchema} is a POJO {@link Class} and the
configured api-version
- * reaches the structured-output floor; a caller-supplied {@code
response_format} does not
- * make it false, and the request builder raises on that conflict
+ * @return true if {@code outputSchema} is a POJO {@link Class} that
reaches no {@code Map} or
+ * {@code JsonNode} and the configured api-version reaches the
structured-output floor; a
+ * caller-supplied {@code response_format} does not make it false, and
the request builder
+ * raises on that conflict
*/
private boolean canApplyNativeStructuredOutput(
Object outputSchema, List<Tool> tools, Map<String, Object>
modelParams) {
- return outputSchema instanceof Class &&
apiVersionSupportsStructuredOutput();
+ return outputSchema instanceof Class
+ && apiVersionSupportsStructuredOutput()
+ && OpenAIChatCompletionsUtils.canDescribeNatively((Class<?>)
outputSchema);
}
/**
diff --git
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
index c2a96e367..eab73a8a5 100644
---
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
+++
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIChatCompletionsUtils.java
@@ -19,6 +19,7 @@ package
org.apache.flink.agents.integrations.chatmodels.openai;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.victools.jsonschema.generator.Option;
import com.openai.core.JsonValue;
@@ -178,6 +179,26 @@ final class OpenAIChatCompletionsUtils {
}
}
+ // Strict mode requires additionalProperties false on every object, so the
native schema
+ // closes every object it describes. A Map's keys are not known in
advance, and a Jackson
+ // JsonNode holds any JSON value, which the generator describes as an
object. Closed, either
+ // admits only {}, so a model held to the schema returns that and the data
is lost.
+ private static final Option[] NATIVE_SCHEMA_OPTIONS = {
+ Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT
+ };
+
+ private static final List<Class<?>> TYPES_WITHOUT_STRICT_FORM =
+ List.of(Map.class, JsonNode.class);
+
+ /**
+ * Whether the strict {@code json_schema} response format can state {@code
schemaClass}'s
+ * contract, which it cannot once the schema reaches a {@code Map} or a
{@code JsonNode}.
+ */
+ static boolean canDescribeNatively(Class<?> schemaClass) {
+ return !PojoJsonSchemaGenerator.reachesAny(
+ schemaClass, TYPES_WITHOUT_STRICT_FORM, NATIVE_SCHEMA_OPTIONS);
+ }
+
/**
* Derives the strict {@code json_schema} response format for a POJO
output schema class.
*
@@ -188,8 +209,7 @@ final class OpenAIChatCompletionsUtils {
*/
static ResponseFormatJsonSchema toNativeResponseFormat(Class<?>
schemaClass) {
Map<String, JsonValue> schema = new LinkedHashMap<>();
- PojoJsonSchemaGenerator.generateAllRequired(
- schemaClass,
Option.FORBIDDEN_ADDITIONAL_PROPERTIES_BY_DEFAULT)
+ PojoJsonSchemaGenerator.generateAllRequired(schemaClass,
NATIVE_SCHEMA_OPTIONS)
.fields()
.forEachRemaining(
member ->
diff --git
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnection.java
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnection.java
index bc3cf9fd5..6c2fd055b 100644
---
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnection.java
+++
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnection.java
@@ -209,18 +209,23 @@ public class OpenAICompletionsConnection extends
BaseChatModelConnection {
*
* <p>Only a POJO {@link Class} has a native translation here; a {@code
RowTypeInfo} wrapped in
* {@code OutputSchema}, or any other form, has none and keeps the
prompt-engineering fallback.
- * Nothing else about the request constrains the native branch, so neither
the tools nor the
- * parameters are read: this connection sends a native schema alongside
bound tools, and the one
- * parameter that would matter is the model, which is the capability
question this excludes.
+ * Nor does a class whose schema reaches a {@code Map} or a {@code
JsonNode}: strict mode closes
+ * every object, so the model could return only {@code {}} for one, and
the class keeps the
+ * fallback too. Nothing else about the request constrains the native
branch, so neither the
+ * tools nor the parameters are read: this connection sends a native
schema alongside bound
+ * tools, and the one parameter that would matter is the model, which is
the capability question
+ * this excludes.
*
* @param outputSchema the schema the request would carry, or null for an
unconstrained request
* @param tools not read; bound tools do not stop this connection sending
a native schema
* @param modelParams not read
- * @return true if {@code outputSchema} is a POJO {@link Class}
+ * @return true if {@code outputSchema} is a POJO {@link Class} that
reaches no {@code Map} or
+ * {@code JsonNode}
*/
private boolean canApplyNativeStructuredOutput(
Object outputSchema, List<Tool> tools, Map<String, Object>
modelParams) {
- return outputSchema instanceof Class;
+ return outputSchema instanceof Class
+ && OpenAIChatCompletionsUtils.canDescribeNatively((Class<?>)
outputSchema);
}
/**
diff --git
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java
index a4bd0bfd5..2f0ffd404 100644
---
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java
+++
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/AzureOpenAIChatModelConnectionTest.java
@@ -131,6 +131,18 @@ class AzureOpenAIChatModelConnectionTest {
public Status status;
}
+ /** Output schema fixture with a {@code Map} field, whose keys are not
known in advance. */
+ public static class Tally {
+ public String label;
+ public Map<String, Integer> counts;
+ }
+
+ /** Output schema fixture with a {@code JsonNode} field, which holds any
JSON value. */
+ public static class Note {
+ public String title;
+ public JsonNode body;
+ }
+
/** Enum constants deserialized from their {@code @JsonProperty} values. */
public enum Status {
@JsonProperty("in-progress")
@@ -503,6 +515,26 @@ class AzureOpenAIChatModelConnectionTest {
assertThat(request.responseFormat()).isEmpty();
}
+ @Test
+ @DisplayName("Native NOT applied for a schema reaching a Map or a
JsonNode")
+ void testNativeNotAppliedForOpenObjectSchema() {
+ // Strict mode closes every object, so the native schema would admit
only {} for the map or
+ // the tree and the model would return that. The prompt path keeps the
field's contents.
+ for (Class<?> schema : List.of(Tally.class, Note.class)) {
+ ChatCompletionCreateParams request =
+ connection()
+ .buildRequest(userMessage(), List.of(),
params("gpt-4o-mini"), schema);
+
+
assertThat(request.responseFormat()).as(schema.getSimpleName()).isEmpty();
+ assertThat(
+ connection()
+ .supportsNativeStructuredOutput(
+ schema, List.of(),
params("gpt-4o-mini")))
+ .as(schema.getSimpleName())
+ .isEqualTo(NativeStructuredOutputSupport.INFEASIBLE);
+ }
+ }
+
@Test
@DisplayName("Native applied for a POJO even when tools are bound")
void testNativeAppliedEvenWhenToolsBound() {
@@ -596,6 +628,9 @@ class AzureOpenAIChatModelConnectionTest {
Arguments.of(
"non_pojo_schema", CAPABLE_API_VERSION, "gpt-4o-mini",
"row<name STRING>"),
Arguments.of("no_output_schema", CAPABLE_API_VERSION,
"gpt-4o-mini", null),
+ Arguments.of("schema_with_map", CAPABLE_API_VERSION,
"gpt-4o-mini", Tally.class),
+ Arguments.of(
+ "schema_with_json_node", CAPABLE_API_VERSION,
"gpt-4o-mini", Note.class),
Arguments.of(
"api_version_below_floor",
BELOW_FLOOR_API_VERSION,
@@ -833,7 +868,9 @@ class AzureOpenAIChatModelConnectionTest {
for (String apiVersion : List.of(CAPABLE_API_VERSION,
BELOW_FLOOR_API_VERSION)) {
AzureOpenAIChatModelConnection connection = connection(apiVersion);
- for (Object schema : Arrays.asList(Person.class, "row<name
STRING>", null)) {
+ for (Object schema :
+ Arrays.asList(
+ Person.class, Tally.class, Note.class, "row<name
STRING>", null)) {
for (List<Tool> tools :
Arrays.asList(List.<Tool>of(), List.<Tool>of(new
StubTool()), null)) {
for (String backing : Arrays.asList("gpt-4o-mini",
"gpt-3.5-turbo", null)) {
diff --git
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnectionTest.java
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnectionTest.java
index 05cf78890..7dfb88ce2 100644
---
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnectionTest.java
+++
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnectionTest.java
@@ -141,6 +141,18 @@ class OpenAICompletionsConnectionTest {
public Person author;
}
+ /** Output schema fixture with a {@code Map} field, whose keys are not
known in advance. */
+ public static class Tally {
+ public String label;
+ public Map<String, Integer> counts;
+ }
+
+ /** Output schema fixture with a {@code JsonNode} field, which holds any
JSON value. */
+ public static class Note {
+ public String title;
+ public JsonNode body;
+ }
+
/** The JSON Schema the request carries, as a JSON tree. */
private static JsonNode nativeSchema(ChatCompletionCreateParams params) {
return ObjectMappers.jsonMapper()
@@ -510,6 +522,25 @@ class OpenAICompletionsConnectionTest {
assertThat(params.responseFormat()).isEmpty();
}
+ @Test
+ @DisplayName("Native NOT applied for a schema reaching a Map or a
JsonNode")
+ void testNativeNotAppliedForOpenObjectSchema() {
+ // Strict mode closes every object, so the native schema would admit
only {} for the map or
+ // the tree and the model would return that. The prompt path keeps the
field's contents.
+ for (Class<?> schema : List.of(Tally.class, Note.class)) {
+ ChatCompletionCreateParams params =
+ connection().buildRequest(userMessage(), List.of(),
params("gpt-4o"), schema);
+
+
assertThat(params.responseFormat()).as(schema.getSimpleName()).isEmpty();
+ assertThat(
+ connection()
+ .supportsNativeStructuredOutput(
+ schema, List.of(),
params("gpt-4o")))
+ .as(schema.getSimpleName())
+ .isEqualTo(NativeStructuredOutputSupport.INFEASIBLE);
+ }
+ }
+
@Test
@DisplayName("Native applied for a POJO even when tools are bound (no
empty-tools gate)")
void testNativeAppliedEvenWhenToolsBound() {
@@ -601,7 +632,8 @@ class OpenAICompletionsConnectionTest {
// capable in every case, so feasibility alone decides the branch.
OpenAICompletionsConnection connection = connection();
- for (Object schema : Arrays.asList(Person.class, "row<name STRING>",
null)) {
+ for (Object schema :
+ Arrays.asList(Person.class, Tally.class, Note.class, "row<name
STRING>", null)) {
for (List<Tool> tools :
Arrays.asList(List.<Tool>of(), List.<Tool>of(new
StubTool()), null)) {
NativeStructuredOutputSupport answer =
diff --git
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/VLLMChatModelConnectionTest.java
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/VLLMChatModelConnectionTest.java
index f09219d96..e8727f325 100644
---
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/VLLMChatModelConnectionTest.java
+++
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/VLLMChatModelConnectionTest.java
@@ -109,6 +109,12 @@ class VLLMChatModelConnectionTest {
public int age;
}
+ /** Output schema fixture with a {@code Map} field, whose keys are not
known in advance. */
+ public static class Tally {
+ public String label;
+ public Map<String, Integer> counts;
+ }
+
@Test
@DisplayName("Native is recommended for any served model, not only OpenAI
model names")
void testQueryRecommendsNativeForServedModels() {
@@ -129,6 +135,13 @@ class VLLMChatModelConnectionTest {
List.of(),
Map.of("model", "Qwen/Qwen2.5-7B-Instruct")))
.isEqualTo(NativeStructuredOutputSupport.INFEASIBLE);
+ // Nor is the strict form's exclusion of a schema reaching a Map,
which vLLM inherits.
+ assertThat(
+ conn.supportsNativeStructuredOutput(
+ Tally.class,
+ List.of(),
+ Map.of("model", "Qwen/Qwen2.5-7B-Instruct")))
+ .isEqualTo(NativeStructuredOutputSupport.INFEASIBLE);
}
private static NativeStructuredOutputSupport support(
diff --git a/python/flink_agents/api/agents/tests/test_output_schema_render.py
b/python/flink_agents/api/agents/tests/test_output_schema_render.py
index 5253fa6cc..bd6d8917a 100644
--- a/python/flink_agents/api/agents/tests/test_output_schema_render.py
+++ b/python/flink_agents/api/agents/tests/test_output_schema_render.py
@@ -15,13 +15,13 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#################################################################################
-from typing import Any, Callable
+from typing import Annotated, Any, Callable, Mapping
import pytest
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict, RootModel, StringConstraints
from pydantic.errors import PydanticInvalidForJsonSchema
-from flink_agents.api.agents.types import render_output_schema
+from flink_agents.api.agents.types import contains_open_object,
render_output_schema
def _render(model: type[BaseModel]) -> dict[str, Any]:
@@ -154,3 +154,129 @@ def test_renderer_failure_raises_chained() -> None:
render_output_schema(Renderable, _render_failing)
assert isinstance(exc_info.value.__cause__, ValueError)
+
+
+class TypedValues(BaseModel):
+ counts: dict[str, int]
+
+
+class AnyDict(BaseModel):
+ attributes: dict[str, Any]
+
+
+class BareDict(BaseModel):
+ attributes: dict
+
+
+class MappingField(BaseModel):
+ counts: Mapping[str, int]
+
+
+class OptionalDict(BaseModel):
+ counts: dict[str, int] | None = None
+
+
+class ListOfDicts(BaseModel):
+ rows: list[dict[str, int]]
+
+
+class DictOfModels(BaseModel):
+ by_name: dict[str, Renderable]
+
+
+class NestsDict(BaseModel):
+ inner: TypedValues
+
+
+class ConstrainedKeys(BaseModel):
+ counts: dict[Annotated[str, StringConstraints(pattern="^[a-z]+$")], int]
+
+
+class TupleWithDict(BaseModel):
+ pair: tuple[int, dict[str, int]]
+
+
+class ModelOrDict(BaseModel):
+ value: Renderable | dict[str, int]
+
+
+class AllowsExtra(BaseModel):
+ model_config = ConfigDict(extra="allow")
+ name: str
+
+
+class DictRoot(RootModel[dict[str, int]]):
+ pass
+
+
+class NestsRenderable(BaseModel):
+ inner: Renderable
+ items: list[Renderable]
+
+
+class AnyField(BaseModel):
+ value: Any
+
+
+class ForbidsExtra(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ name: str
+
+
+class KeywordNamedField(BaseModel):
+ additionalProperties: int
+ patternProperties: str
+
+
[email protected](
+ "model",
+ [
+ TypedValues,
+ AnyDict,
+ BareDict,
+ MappingField,
+ OptionalDict,
+ ListOfDicts,
+ DictOfModels,
+ NestsDict,
+ ConstrainedKeys,
+ TupleWithDict,
+ ModelOrDict,
+ AllowsExtra,
+ DictRoot,
+ ],
+)
+def test_schema_admitting_undeclared_keys_is_open(model: type[BaseModel]) ->
None:
+ """A dict anywhere in the schema, or an extra-allowing model, is found.
+
+ A strict native schema closes every object, so it would admit only ``{}``
for
+ each of these, or be refused.
+ """
+ assert contains_open_object(model)
+
+
[email protected](
+ "model",
+ [
+ Renderable,
+ NestsRenderable,
+ SelfReferential,
+ MutualA,
+ FieldLess,
+ AnyField,
+ ForbidsExtra,
+ KeywordNamedField,
+ ],
+)
+def test_schema_declaring_every_key_is_not_open(model: type[BaseModel]) ->
None:
+ """A schema whose objects all declare their keys is left to the native
path.
+
+ That includes a field named after a keyword, since keys under
``properties``
+ are names rather than keywords.
+ """
+ assert not contains_open_object(model)
+
+
+def test_model_without_a_json_schema_is_not_reported_open() -> None:
+ """The render reports that failure clearly, so this check leaves it
alone."""
+ assert not contains_open_object(Unrenderable)
diff --git a/python/flink_agents/api/agents/types.py
b/python/flink_agents/api/agents/types.py
index a0f5efffe..381f0da1e 100644
--- a/python/flink_agents/api/agents/types.py
+++ b/python/flink_agents/api/agents/types.py
@@ -128,3 +128,54 @@ def render_output_schema(
)
raise TypeError(msg)
return schema
+
+
+def contains_open_object(model: type[BaseModel]) -> bool:
+ """Whether ``model``'s JSON Schema admits keys it does not declare, at any
depth.
+
+ A ``dict`` field does, wherever it sits, and so does a model configured
with
+ ``extra="allow"``: their keys are not known in advance. A strict native
schema
+ requires every object to be closed, so it cannot state either, and a
connection
+ whose native form is strict keeps the prompt path for such a model.
+
+ Only the keywords that hold subschemas are followed. Keys under
``properties`` and
+ ``$defs`` are names, so a field named ``additionalProperties`` does not
count.
+
+ Args:
+ model: The model class describing the shape the response must take.
+
+ Returns:
+ ``True`` if some object in the schema sets ``additionalProperties`` to
+ anything but ``False``, or sets ``patternProperties``. ``False`` when
the
+ model has no JSON Schema, leaving that failure to
``render_output_schema``,
+ which reports it clearly.
+ """
+ try:
+ schema = model.model_json_schema()
+ except Exception:
+ return False
+ return _admits_undeclared_keys(schema)
+
+
+def _admits_undeclared_keys(schema: Any) -> bool:
+ if not isinstance(schema, dict):
+ return False
+ if schema.get("additionalProperties", False) is not False:
+ return True
+ if "patternProperties" in schema:
+ return True
+ for keyword in ("properties", "$defs"):
+ subschemas = schema.get(keyword)
+ if isinstance(subschemas, dict) and any(
+ _admits_undeclared_keys(subschema) for subschema in
subschemas.values()
+ ):
+ return True
+ for keyword in ("prefixItems", "anyOf", "oneOf", "allOf"):
+ subschemas = schema.get(keyword)
+ if isinstance(subschemas, list) and any(
+ _admits_undeclared_keys(subschema) for subschema in subschemas
+ ):
+ return True
+ return _admits_undeclared_keys(schema.get("items")) or
_admits_undeclared_keys(
+ schema.get("not")
+ )
diff --git
a/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
b/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
index bef145dbd..7264d5a3c 100644
---
a/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
+++
b/python/flink_agents/integrations/chat_models/anthropic/anthropic_chat_model.py
@@ -27,7 +27,11 @@ from anthropic.types import MessageParam, TextBlockParam,
ToolParam
from pydantic import BaseModel, Field, PrivateAttr
from typing_extensions import override
-from flink_agents.api.agents.types import OutputSchema, render_output_schema
+from flink_agents.api.agents.types import (
+ OutputSchema,
+ contains_open_object,
+ render_output_schema,
+)
from flink_agents.api.chat_message import (
Base64Source,
ChatMessage,
@@ -521,11 +525,14 @@ class
AnthropicChatModelConnection(BaseChatModelConnection):
"""Whether a request built from these inputs would carry a derived
``output_config``, leaving the effective model's capability out of the
answer.
- Two conditions. Only a ``BaseModel`` subclass has a native translation
here; a
- ``RowTypeInfo`` wrapped in ``OutputSchema``, or no schema at all, has
none. And
- a caller-supplied ``output_config`` wins: it is the caller being
explicit about
- the exact parameter the native branch writes, so the schema keeps the
- prompt-engineering fallback rather than the two competing on one
request.
+ Two conditions. Only a ``BaseModel`` subclass has a native translation
here,
+ and only one whose schema admits no undeclared keys: a ``dict`` field
admits
+ them, and the derived schema closes every object, so the model could
return
+ only ``{}`` for one. A ``RowTypeInfo`` wrapped in ``OutputSchema``, or
no
+ schema at all, has none. And a caller-supplied ``output_config`` wins:
it is
+ the caller being explicit about the exact parameter the native branch
writes,
+ so the schema keeps the prompt-engineering fallback rather than the two
+ competing on one request.
The tools are not read; a bound tool does not stop this connection
sending a
schema.
@@ -549,11 +556,15 @@ class
AnthropicChatModelConnection(BaseChatModelConnection):
Returns:
-------
bool
- ``True`` if ``output_schema`` wraps a ``BaseModel`` subclass and
the caller
- supplied no ``output_config`` of its own.
+ ``True`` if ``output_schema`` wraps a ``BaseModel`` subclass whose
schema
+ admits no undeclared keys, and the caller supplied no
``output_config`` of
+ its own.
"""
- return _native_output_model(output_schema) is not None and (
- model_kwargs is None or "output_config" not in model_kwargs
+ model = _native_output_model(output_schema)
+ return (
+ model is not None
+ and (model_kwargs is None or "output_config" not in model_kwargs)
+ and not contains_open_object(model)
)
def chat(
diff --git
a/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
b/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
index 4aad9e30c..8139d1a65 100644
---
a/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
+++
b/python/flink_agents/integrations/chat_models/anthropic/tests/test_anthropic_response_parsing.py
@@ -22,7 +22,7 @@ from unittest.mock import MagicMock
import pytest
from anthropic import transform_schema
from anthropic.types import Message, TextBlock, ToolUseBlock, Usage
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
from pyflink.common.typeinfo import Types
from flink_agents.api.agents.types import OutputSchema
@@ -232,11 +232,19 @@ class _MapsToFieldLess(BaseModel):
class _Labelled(BaseModel):
- """A schema whose only member is a free-form map, a legitimate
constraint."""
+ """A schema whose only member is a map, which the renderer closes empty."""
labels: Dict[str, str]
+class _AllowsExtra(BaseModel):
+ """A schema admitting undeclared keys at its root, which the renderer
closes."""
+
+ model_config = ConfigDict(extra="allow")
+
+ verdict: str
+
+
# A model the provider documents native structured-output support for.
_CAPABLE_MODEL = "claude-sonnet-4-5"
@@ -314,11 +322,11 @@ def test_unrenderable_schema_raises_naming_the_model() ->
None:
)
[email protected]("schema", [_FieldLess, _NestsFieldLess,
_MapsToFieldLess])
[email protected]("schema", [_FieldLess, _NestsFieldLess])
def test_field_less_schema_is_accepted_and_sent_whole(schema) -> None:
# A schema declaring no fields renders, so the provider decides on it, not
this
# connection. The document reaches the request exactly as rendered rather
than
- # being refused here. The nested cases carry the field-less model below
the root,
+ # being refused here. The nested case carries the field-less model below
the root,
# so the assertion covers the whole document rather than only its top
level.
output_config = _request_kwargs(
model=_CAPABLE_MODEL, output_schema=OutputSchema(output_schema=schema)
@@ -327,16 +335,22 @@ def
test_field_less_schema_is_accepted_and_sent_whole(schema) -> None:
assert output_config["format"]["schema"] == transform_schema(schema)
-def test_map_member_schema_is_accepted_and_sent_whole() -> None:
- # This renderer rewrites a map member into an object with an empty
properties. The
- # rewritten document is what reaches the request, so the member survives
the
- # normalization rather than being dropped or flattened.
- output_config = _request_kwargs(
- model=_CAPABLE_MODEL,
output_schema=OutputSchema(output_schema=_Labelled)
- )["output_config"]
[email protected]("schema", [_Labelled, _MapsToFieldLess, _AllowsExtra])
+def test_schema_admitting_undeclared_keys_keeps_the_prompt_path(schema) ->
None:
+ # The renderer closes every object, so a map would reach the provider as
an object
+ # with no properties and come back empty, and an open model would lose its
extra
+ # keys, with no error either way. No output_config is sent, and the query
answers
+ # INFEASIBLE in step, which fails a NATIVE policy before the call is made.
+ assert (
+ _connection().supports_native_structured_output(
+ OutputSchema(output_schema=schema), [], {"model": _CAPABLE_MODEL}
+ )
+ is NativeStructuredOutputSupport.INFEASIBLE
+ )
- assert output_config["format"]["schema"] == transform_schema(_Labelled)
- assert
output_config["format"]["schema"]["properties"]["labels"]["properties"] == {}
+ assert "output_config" not in _request_kwargs(
+ model=_CAPABLE_MODEL, output_schema=OutputSchema(output_schema=schema)
+ )
def test_unlisted_model_answers_feasible_and_still_carries_the_schema() ->
None:
@@ -603,6 +617,16 @@ def test_json_prefill_applied_when_schema_falls_back() ->
None:
) == (True, _COMPLETED)
+def test_json_prefill_applied_when_open_schema_falls_back() -> None:
+ # A schema admitting undeclared keys is a BaseModel, so it passes every
check a
+ # RowTypeInfo fails, and still falls back. The prefill has to follow it
there.
+ assert _prefill_outcome(
+ model=_INCAPABLE_MODEL,
+ json_prefill=True,
+ output_schema=OutputSchema(output_schema=_AllowsExtra),
+ ) == (True, _COMPLETED)
+
+
def test_json_prefill_suppressed_on_prefill_unsupported_model() -> None:
assert _prefill_outcome(model="claude-opus-4-6", json_prefill=True) == (
False,
@@ -872,6 +896,7 @@ def test_query_agrees_with_the_native_branch() -> None:
for schema in (
OutputSchema(output_schema=_Answer),
+ OutputSchema(output_schema=_Labelled),
OutputSchema(output_schema=row_type),
None,
):
diff --git
a/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py
b/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py
index 438a9a167..7820f8135 100644
---
a/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py
+++
b/python/flink_agents/integrations/chat_models/azure/azure_openai_chat_model.py
@@ -29,7 +29,11 @@ from openai.lib._pydantic import to_strict_json_schema
from pydantic import BaseModel, Field, PrivateAttr
from typing_extensions import override
-from flink_agents.api.agents.types import OutputSchema, render_output_schema
+from flink_agents.api.agents.types import (
+ OutputSchema,
+ contains_open_object,
+ render_output_schema,
+)
from flink_agents.api.chat_message import ChatMessage
from flink_agents.api.chat_models.chat_model import (
BaseChatModelConnection,
@@ -304,10 +308,12 @@ class
AzureOpenAIChatModelConnection(BaseChatModelConnection):
"""Whether a request built from these inputs would carry a native
``response_format``, leaving the effective model's capability out of
the answer.
- Two conditions. Only a ``BaseModel`` subclass has a native translation
here; a
- ``RowTypeInfo`` wrapped in ``OutputSchema``, or no schema at all, has
none. And
- the configured api-version has to reach the structured-output floor,
which is a
- property of this connection rather than of the request.
+ Two conditions. Only a ``BaseModel`` subclass has a native translation
here,
+ and only one whose schema admits no undeclared keys: a ``dict`` field
admits
+ them, and strict mode requires every object to be closed. A
``RowTypeInfo``
+ wrapped in ``OutputSchema``, or no schema at all, has none. And the
configured
+ api-version has to reach the structured-output floor, which is a
property of
+ this connection rather than of the request.
A caller-supplied ``response_format`` is deliberately not a condition:
the
branch sets the format and then raises on that conflict rather than
skipping,
@@ -334,12 +340,15 @@ class
AzureOpenAIChatModelConnection(BaseChatModelConnection):
Returns:
-------
bool
- ``True`` if ``output_schema`` wraps a ``BaseModel`` subclass and
the
- configured api-version reaches the structured-output floor.
+ ``True`` if ``output_schema`` wraps a ``BaseModel`` subclass whose
schema
+ admits no undeclared keys, and the configured api-version reaches
the
+ structured-output floor.
"""
+ model = _native_output_model(output_schema)
return (
- _native_output_model(output_schema) is not None
+ model is not None
and self._api_version_supports_structured_output()
+ and not contains_open_object(model)
)
def chat(
diff --git
a/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_native_structured_output.py
b/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_native_structured_output.py
index 0ab06ed2b..1d549f4f1 100644
---
a/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_native_structured_output.py
+++
b/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_native_structured_output.py
@@ -20,7 +20,7 @@ from unittest.mock import MagicMock
import pytest
from openai.lib._pydantic import to_strict_json_schema
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
from pyflink.common.typeinfo import Types
from flink_agents.api.agents.types import OutputSchema
@@ -74,11 +74,19 @@ class MapsToFieldLess(BaseModel):
class Labelled(BaseModel):
- """A schema whose only member is a free-form map, a legitimate
constraint."""
+ """A schema whose only member is a map, which strict mode cannot
express."""
labels: dict[str, str]
+class AllowsExtra(BaseModel):
+ """A schema admitting undeclared keys at its root, which strict mode
forbids."""
+
+ model_config = ConfigDict(extra="allow")
+
+ name: str
+
+
ROW_TYPE = Types.ROW_NAMED(["name"], [Types.STRING()])
@@ -492,12 +500,12 @@ def test_unrenderable_schema_raises_naming_the_model() ->
None:
_chat_with_schema(_connection(), Unrenderable)
[email protected]("schema", [FieldLess, NestsFieldLess,
MapsToFieldLess])
[email protected]("schema", [FieldLess, NestsFieldLess])
def test_field_less_schema_is_accepted_and_sent_whole(schema: type[BaseModel])
-> None:
"""A schema declaring no fields renders, so the provider decides on it,
not us.
The document reaches the request exactly as rendered rather than being
refused
- here. The nested cases carry the field-less model below the root, so the
+ here. The nested case carries the field-less model below the root, so the
assertion covers the whole document rather than only its top level.
"""
conn = _connection()
@@ -506,12 +514,26 @@ def
test_field_less_schema_is_accepted_and_sent_whole(schema: type[BaseModel]) -
assert response_format["json_schema"]["schema"] ==
to_strict_json_schema(schema)
-def test_map_member_schema_is_accepted_and_sent_whole() -> None:
- """A free-form map is a legitimate constraint and reaches the request
intact."""
[email protected]("schema", [Labelled, MapsToFieldLess, AllowsExtra])
+def test_schema_admitting_undeclared_keys_keeps_the_prompt_path(
+ schema: type[BaseModel],
+) -> None:
+ """A map or an open model has no strict form, so no response_format is
sent.
+
+ Strict mode requires every object to forbid undeclared keys, and these keep
+ theirs open, so the provider would reject the request. The query answers
+ INFEASIBLE in step, which fails a NATIVE policy before the call is made.
+ """
conn = _connection()
- _chat_with_schema(conn, Labelled)
- response_format = _create_call_kwargs(conn)["response_format"]
- assert response_format["json_schema"]["schema"] ==
to_strict_json_schema(Labelled)
+ assert (
+ conn.supports_native_structured_output(
+ OutputSchema(output_schema=schema), [],
_model_kwargs("gpt-4o-mini")
+ )
+ is NativeStructuredOutputSupport.INFEASIBLE
+ )
+
+ _chat_with_schema(conn, schema)
+ assert "response_format" not in _create_call_kwargs(conn)
def _model_kwargs(backing_model: str | None = None) -> dict[str, Any]:
@@ -572,6 +594,7 @@ def test_query_agrees_with_the_native_branch() -> None:
for schema in (
OutputSchema(output_schema=Person),
+ OutputSchema(output_schema=Labelled),
OutputSchema(output_schema=ROW_TYPE),
None,
):
diff --git
a/python/flink_agents/integrations/chat_models/openai/openai_chat_model.py
b/python/flink_agents/integrations/chat_models/openai/openai_chat_model.py
index afdcd70e4..9865c5195 100644
--- a/python/flink_agents/integrations/chat_models/openai/openai_chat_model.py
+++ b/python/flink_agents/integrations/chat_models/openai/openai_chat_model.py
@@ -28,7 +28,11 @@ from openai.lib._pydantic import to_strict_json_schema
from pydantic import BaseModel, Field, PrivateAttr
from typing_extensions import override
-from flink_agents.api.agents.types import OutputSchema, render_output_schema
+from flink_agents.api.agents.types import (
+ OutputSchema,
+ contains_open_object,
+ render_output_schema,
+)
from flink_agents.api.chat_message import ChatMessage
from flink_agents.api.chat_models.chat_model import (
BaseChatModelConnection,
@@ -285,8 +289,10 @@ class OpenAIChatModelConnection(BaseChatModelConnection):
Only a ``BaseModel`` subclass has a native translation here; a
``RowTypeInfo``
wrapped in ``OutputSchema``, or no schema at all, has none and keeps
the
- prompt-engineering fallback. Nothing else about the request constrains
the
- native branch, so neither the tools nor the parameters are read: this
connection
+ prompt-engineering fallback. So does a model whose schema admits
undeclared
+ keys, such as one with a ``dict`` field, since strict mode requires
every
+ object to be closed. Nothing else about the request constrains the
native
+ branch, so neither the tools nor the parameters are read: this
connection
sends a native schema alongside bound tools, and the one parameter
that would
bear on the answer is the model, which is the capability question this
excludes.
@@ -307,9 +313,11 @@ class OpenAIChatModelConnection(BaseChatModelConnection):
Returns:
-------
bool
- ``True`` if ``output_schema`` wraps a ``BaseModel`` subclass.
+ ``True`` if ``output_schema`` wraps a ``BaseModel`` subclass whose
schema
+ admits no undeclared keys.
"""
- return _native_output_model(output_schema) is not None
+ model = _native_output_model(output_schema)
+ return model is not None and not contains_open_object(model)
def chat(
self,
diff --git
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
index 42abf8fbb..6f160d6c6 100644
---
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
+++
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
@@ -20,7 +20,7 @@ from unittest.mock import MagicMock
import pytest
from openai.lib._pydantic import to_strict_json_schema
-from pydantic import BaseModel
+from pydantic import BaseModel, ConfigDict
from pyflink.common.typeinfo import Types
from flink_agents.api.agents.types import OutputSchema
@@ -63,11 +63,19 @@ class MapsToFieldLess(BaseModel):
class Labelled(BaseModel):
- """A schema whose only member is a free-form map, a legitimate
constraint."""
+ """A schema whose only member is a map, which strict mode cannot
express."""
labels: dict[str, str]
+class AllowsExtra(BaseModel):
+ """A schema admitting undeclared keys at its root, which strict mode
forbids."""
+
+ model_config = ConfigDict(extra="allow")
+
+ name: str
+
+
def _connection() -> OpenAIChatModelConnection:
conn = OpenAIChatModelConnection(
api_key="test-key", api_base_url="http://localhost"
@@ -271,12 +279,12 @@ def test_unrenderable_schema_raises_naming_the_model() ->
None:
_chat_with_schema(_connection(), Unrenderable)
[email protected]("schema", [FieldLess, NestsFieldLess,
MapsToFieldLess])
[email protected]("schema", [FieldLess, NestsFieldLess])
def test_field_less_schema_is_accepted_and_sent_whole(schema: type[BaseModel])
-> None:
"""A schema declaring no fields renders, so the provider decides on it,
not us.
The document reaches the request exactly as rendered rather than being
refused
- here. The nested cases carry the field-less model below the root, so the
+ here. The nested case carries the field-less model below the root, so the
assertion covers the whole document rather than only its top level.
"""
conn = _connection()
@@ -285,12 +293,26 @@ def
test_field_less_schema_is_accepted_and_sent_whole(schema: type[BaseModel]) -
assert response_format["json_schema"]["schema"] ==
to_strict_json_schema(schema)
-def test_map_member_schema_is_accepted_and_sent_whole() -> None:
- """A free-form map is a legitimate constraint and reaches the request
intact."""
[email protected]("schema", [Labelled, MapsToFieldLess, AllowsExtra])
+def test_schema_admitting_undeclared_keys_keeps_the_prompt_path(
+ schema: type[BaseModel],
+) -> None:
+ """A map or an open model has no strict form, so no response_format is
sent.
+
+ Strict mode requires every object to forbid undeclared keys, and these keep
+ theirs open, so the provider would reject the request. The query answers
+ INFEASIBLE in step, which fails a NATIVE policy before the call is made.
+ """
conn = _connection()
- _chat_with_schema(conn, Labelled)
- response_format = _create_call_kwargs(conn)["response_format"]
- assert response_format["json_schema"]["schema"] ==
to_strict_json_schema(Labelled)
+ assert (
+ conn.supports_native_structured_output(
+ OutputSchema(output_schema=schema), [], {"model": "gpt-4o"}
+ )
+ is NativeStructuredOutputSupport.INFEASIBLE
+ )
+
+ _chat_with_schema(conn, schema)
+ assert "response_format" not in _create_call_kwargs(conn)
def _judging_connection() -> tuple[OpenAIChatModelConnection, list[str |
None]]:
@@ -366,6 +388,7 @@ def test_query_agrees_with_the_native_branch() -> None:
for schema in (
OutputSchema(output_schema=Person),
+ OutputSchema(output_schema=Labelled),
OutputSchema(output_schema=row_type),
None,
):
diff --git
a/python/flink_agents/integrations/chat_models/vllm/tests/test_vllm_chat_model.py
b/python/flink_agents/integrations/chat_models/vllm/tests/test_vllm_chat_model.py
index 9547e1b4d..643ca3faa 100644
---
a/python/flink_agents/integrations/chat_models/vllm/tests/test_vllm_chat_model.py
+++
b/python/flink_agents/integrations/chat_models/vllm/tests/test_vllm_chat_model.py
@@ -83,6 +83,12 @@ class _Person(BaseModel):
age: int
+class _Tally(BaseModel):
+ """A schema carrying a map, which vLLM's guided decoding can enforce."""
+
+ counts: dict[str, int]
+
+
@pytest.mark.parametrize(
("model", "expected"),
[
@@ -138,6 +144,27 @@ def test_native_response_format_applied_for_qwen_model()
-> None:
assert kwargs["response_format"]["type"] == "json_schema"
+def test_map_schema_is_sent_natively_with_its_value_type() -> None:
+ # OpenAI keeps a map off the native path because strict mode rejects it.
vLLM
+ # does not apply strict mode, so the map is sent and keeps its value type.
+ connection, mock_client = _connection_with_mock_client()
+ schema = OutputSchema(output_schema=_Tally)
+ model = "Qwen/Qwen2.5-7B-Instruct"
+
+ assert (
+ connection.supports_native_structured_output(schema, [], {"model":
model})
+ is NativeStructuredOutputSupport.NATIVE_RECOMMENDED
+ )
+
+ connection.chat(
+ [ChatMessage.of(MessageRole.USER, "hi")], model=model,
output_schema=schema
+ )
+
+ kwargs = mock_client.chat.completions.create.call_args.kwargs
+ sent = kwargs["response_format"]["json_schema"]["schema"]
+ assert sent["properties"]["counts"]["additionalProperties"] == {"type":
"integer"}
+
+
def test_capability_override_shapes_only_the_query() -> None:
# A blank model is the one input the vLLM capability check declines. The
query
# reports it FEASIBLE, and the request still carries the schema it was
handed.
diff --git
a/python/flink_agents/integrations/chat_models/vllm/vllm_chat_model.py
b/python/flink_agents/integrations/chat_models/vllm/vllm_chat_model.py
index 1dba9347f..a427801d1 100644
--- a/python/flink_agents/integrations/chat_models/vllm/vllm_chat_model.py
+++ b/python/flink_agents/integrations/chat_models/vllm/vllm_chat_model.py
@@ -15,13 +15,16 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#################################################################################
-from typing import Any
+from typing import Any, List, Mapping
from typing_extensions import override
+from flink_agents.api.agents.types import OutputSchema
+from flink_agents.api.tools.tool import Tool
from flink_agents.integrations.chat_models.openai.openai_chat_model import (
OpenAIChatModelConnection,
OpenAIChatModelSetup,
+ _native_output_model,
)
DEFAULT_VLLM_API_BASE_URL = "http://localhost:8000/v1"
@@ -86,6 +89,23 @@ class VLLMChatModelConnection(OpenAIChatModelConnection):
"""
return bool(effective_model and effective_model.strip())
+ @override
+ def _can_apply_native_structured_output(
+ self,
+ output_schema: OutputSchema | None,
+ tools: List[Tool] | None,
+ model_kwargs: Mapping[str, Any] | None,
+ ) -> bool:
+ """Whether a request built from these inputs would carry a native
+ ``response_format``, leaving the effective model's capability out of
the answer.
+
+ Unlike OpenAI, a schema that admits undeclared keys, such as one with a
+ ``dict`` field, still applies: vLLM does not enforce OpenAI's
strict-mode
+ rules, and its guided decoding honours ``additionalProperties``, so a
map
+ keeps its value type. Only a ``BaseModel`` subclass has a native
translation.
+ """
+ return _native_output_model(output_schema) is not None
+
class VLLMChatModelSetup(OpenAIChatModelSetup):
"""Settings for a chat model served by vLLM.