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 c1570eba [integrations][openai] Apply OpenAI native structured output 
(#919)
c1570eba is described below

commit c1570ebad00ce324ac368924ce573b5a10f6cfba
Author: Weiqing Yang <[email protected]>
AuthorDate: Sun Aug 2 02:18:20 2026 -0700

    [integrations][openai] Apply OpenAI native structured output (#919)
---
 .../openai/OpenAICompletionsConnection.java        | 106 +++++++++-
 .../openai/OpenAICompletionsConnectionTest.java    | 218 +++++++++++++++++++++
 .../chat_models/openai/openai_chat_model.py        | 108 +++++++++-
 .../tests/test_openai_native_structured_output.py  | 204 +++++++++++++++++++
 .../tests/test_output_schema_param_declared.py     |  24 ++-
 5 files changed, 650 insertions(+), 10 deletions(-)

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 29d0dcf7..92683b9c 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
@@ -22,11 +22,13 @@ import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.openai.client.OpenAIClient;
 import com.openai.client.okhttp.OpenAIOkHttpClient;
+import com.openai.core.JsonSchemaLocalValidation;
 import com.openai.core.JsonValue;
 import com.openai.models.ChatModel;
 import com.openai.models.FunctionDefinition;
 import com.openai.models.FunctionParameters;
 import com.openai.models.ReasoningEffort;
+import com.openai.models.ResponseFormatJsonSchema;
 import com.openai.models.chat.completions.ChatCompletion;
 import com.openai.models.chat.completions.ChatCompletionCreateParams;
 import com.openai.models.chat.completions.ChatCompletionFunctionTool;
@@ -43,6 +45,7 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 /**
  * A chat model integration for the OpenAI Chat Completions service using the 
official Java SDK.
@@ -119,11 +122,70 @@ public class OpenAICompletionsConnection extends 
BaseChatModelConnection {
         this.client = builder.build();
     }
 
+    // Models for which OpenAI documents json_schema strict Structured Outputs 
support.
+    // Source of truth: 
https://platform.openai.com/docs/guides/structured-outputs
+    //
+    // A name carrying a non-text modality marker is rejected before anything 
else. Audio, realtime,
+    // speech and transcription variants expose no json_schema response format 
even though they
+    // share the name prefix of a capable text family, so the marker check has 
to win over a prefix
+    // match rather than merely coexist with it.
+    //
+    // A text family whose entire lifetime post-dates the Structured Outputs 
cutoff is matched by
+    // name prefix, so its dated snapshots and size variants resolve without 
enumerating each one.
+    //
+    // Two cases cannot use a prefix and are matched exactly instead. The 
gpt-4o family straddles
+    // the cutoff — gpt-4o-2024-05-13 predates it and is not capable — so the 
boundary there is
+    // temporal rather than nominal. The o1 family is not uniform: o1 is 
capable while o1-mini is
+    // not, so an "o1" prefix would admit an incapable sibling.
+    //
+    // A name outside every listed family reports not-capable and degrades to 
the prompt fallback
+    // rather than failing at the provider. Within a listed family the prefix 
assumes capability,
+    // so a family variant that ships without json_schema support has to be 
excluded explicitly,
+    // either by a marker that appears in no capable name or by replacing the 
family prefix with
+    // exact names.
+    private static final Set<String> NON_TEXT_MODALITY_MARKERS =
+            Set.of("-audio", "-realtime", "-tts", "-transcribe");
+    private static final Set<String> NATIVE_STRUCTURED_OUTPUT_FAMILY_PREFIXES =
+            Set.of("gpt-4o-mini", "gpt-4o-search-preview", "gpt-4.1", "gpt-5", 
"o3", "o4-mini");
+    private static final Set<String> NATIVE_STRUCTURED_OUTPUT_MODELS =
+            Set.of("gpt-4o", "gpt-4o-2024-08-06", "gpt-4o-2024-11-20", "o1", 
"o1-2024-12-17");
+
+    @Override
+    protected boolean supportsNativeStructuredOutput(String effectiveModel) {
+        if (effectiveModel == null) {
+            return false;
+        }
+        if 
(NON_TEXT_MODALITY_MARKERS.stream().anyMatch(effectiveModel::contains)) {
+            return false;
+        }
+        return NATIVE_STRUCTURED_OUTPUT_FAMILY_PREFIXES.stream()
+                        .anyMatch(effectiveModel::startsWith)
+                || NATIVE_STRUCTURED_OUTPUT_MODELS.contains(effectiveModel);
+    }
+
     @Override
     public ChatMessage chat(
             List<ChatMessage> messages, List<Tool> tools, Map<String, Object> 
modelParams) {
+        return doChat(messages, tools, modelParams, null);
+    }
+
+    @Override
+    public ChatMessage chat(
+            List<ChatMessage> messages,
+            List<Tool> tools,
+            Map<String, Object> modelParams,
+            Object outputSchema) {
+        return doChat(messages, tools, modelParams, outputSchema);
+    }
+
+    private ChatMessage doChat(
+            List<ChatMessage> messages,
+            List<Tool> tools,
+            Map<String, Object> modelParams,
+            Object outputSchema) {
         try {
-            ChatCompletionCreateParams params = buildRequest(messages, tools, 
modelParams);
+            ChatCompletionCreateParams params =
+                    buildRequest(messages, tools, modelParams, outputSchema);
             ChatCompletion completion = 
client.chat().completions().create(params);
             ChatMessage response =
                     OpenAIChatCompletionsUtils.convertFromOpenAIMessage(
@@ -150,8 +212,13 @@ public class OpenAICompletionsConnection extends 
BaseChatModelConnection {
         }
     }
 
-    private ChatCompletionCreateParams buildRequest(
-            List<ChatMessage> messages, List<Tool> tools, Map<String, Object> 
rawModelParams) {
+    // Package-private so the request body (including the native 
response_format) can be asserted
+    // without issuing a live API call through the final OpenAI client.
+    ChatCompletionCreateParams buildRequest(
+            List<ChatMessage> messages,
+            List<Tool> tools,
+            Map<String, Object> rawModelParams,
+            Object outputSchema) {
         Map<String, Object> modelParams =
                 rawModelParams != null ? new HashMap<>(rawModelParams) : new 
HashMap<>();
 
@@ -170,6 +237,19 @@ public class OpenAICompletionsConnection extends 
BaseChatModelConnection {
             builder.tools(convertTools(tools, strictMode));
         }
 
+        // Native structured output applies only for a POJO Class schema on a 
model the provider
+        // documents as capable; a RowTypeInfo (wrapped in OutputSchema) or an 
incapable model keeps
+        // the prompt-engineering fallback.
+        //
+        // TODO(#912): the requested strategy is not visible here, so this 
re-check cannot tell an
+        // explicit NATIVE request apart from one that merely resolved to 
native. A caller asking
+        // for NATIVE on a model this predicate rejects therefore gets an 
unconstrained response
+        // instead of an error. Once strategy resolution is wired up, NATIVE 
must either bypass
+        // this capability re-check or fail explicitly.
+        if (outputSchema instanceof Class && 
supportsNativeStructuredOutput(modelName)) {
+            builder.responseFormat(toNativeResponseFormat((Class<?>) 
outputSchema));
+        }
+
         Object temperature = modelParams.remove("temperature");
         if (temperature instanceof Number) {
             builder.temperature(((Number) temperature).doubleValue());
@@ -208,6 +288,26 @@ public class OpenAICompletionsConnection extends 
BaseChatModelConnection {
         return builder.build();
     }
 
+    // Derives the strict json_schema response format from a POJO class via 
the SDK's typed
+    // structured-output builder. The Kotlin-facade 
StructuredOutputsKt.responseFormatFromClass is
+    // not callable from Java, so the response format is extracted through the 
typed builder, which
+    // generates the same strict draft-2020-12 schema, and then reattached to 
the standard builder.
+    private static <T> ResponseFormatJsonSchema 
toNativeResponseFormat(Class<T> schemaClass) {
+        return ChatCompletionCreateParams.builder()
+                .model(ChatModel.of(""))
+                .addUserMessage("")
+                .responseFormat(schemaClass, JsonSchemaLocalValidation.NO)
+                .build()
+                .rawParams()
+                .responseFormat()
+                .orElseThrow(
+                        () ->
+                                new IllegalStateException(
+                                        "OpenAI SDK did not produce a 
response_format for schema "
+                                                + schemaClass.getName()))
+                .asJsonSchema();
+    }
+
     private List<ChatCompletionTool> convertTools(List<Tool> tools, boolean 
strictMode) {
         List<ChatCompletionTool> openaiTools = new ArrayList<>(tools.size());
         for (Tool tool : tools) {
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
new file mode 100644
index 00000000..be7a966e
--- /dev/null
+++ 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAICompletionsConnectionTest.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.flink.agents.integrations.chatmodels.openai;
+
+import com.openai.models.ResponseFormatJsonSchema;
+import com.openai.models.chat.completions.ChatCompletionCreateParams;
+import org.apache.flink.agents.api.chat.messages.ChatMessage;
+import org.apache.flink.agents.api.chat.messages.MessageRole;
+import org.apache.flink.agents.api.resource.ResourceContext;
+import org.apache.flink.agents.api.resource.ResourceDescriptor;
+import org.apache.flink.agents.api.tools.Tool;
+import org.apache.flink.agents.api.tools.ToolMetadata;
+import org.apache.flink.agents.api.tools.ToolParameters;
+import org.apache.flink.agents.api.tools.ToolResponse;
+import org.apache.flink.agents.api.tools.ToolType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link OpenAICompletionsConnection}'s native 
structured-output behavior. These
+ * assert the built request body without a live API call by inspecting {@code 
buildRequest}, and
+ * exercise the model-dependent capability predicate directly.
+ */
+class OpenAICompletionsConnectionTest {
+
+    private static final ResourceContext NOOP = 
ResourceContext.fromGetResource((a, b) -> null);
+
+    /** A representative POJO output schema. */
+    public static class Person {
+        public String name;
+        public int age;
+    }
+
+    private static OpenAICompletionsConnection connection() {
+        ResourceDescriptor desc =
+                
ResourceDescriptor.Builder.newBuilder(OpenAICompletionsConnection.class.getName())
+                        .addInitialArgument("api_key", "test-key")
+                        .addInitialArgument("model", "gpt-4o")
+                        .build();
+        return new OpenAICompletionsConnection(desc, NOOP);
+    }
+
+    private static Map<String, Object> params(String model) {
+        Map<String, Object> params = new HashMap<>();
+        params.put("model", model);
+        return params;
+    }
+
+    private static List<ChatMessage> userMessage() {
+        return List.of(new ChatMessage(MessageRole.USER, "hi"));
+    }
+
+    @Test
+    @DisplayName("Native response_format json_schema strict applied for a POJO 
on a capable model")
+    void testNativeAppliedForPojoCapableModel() {
+        ChatCompletionCreateParams params =
+                connection().buildRequest(userMessage(), List.of(), 
params("gpt-4o"), Person.class);
+
+        assertThat(params.responseFormat()).isPresent();
+        ResponseFormatJsonSchema jsonSchema = 
params.responseFormat().get().asJsonSchema();
+        assertThat(jsonSchema.jsonSchema().strict()).contains(true);
+    }
+
+    @Test
+    @DisplayName("Native NOT applied for a POJO on an incapable model (prompt 
fallback)")
+    void testNativeNotAppliedForIncapableModel() {
+        ChatCompletionCreateParams params =
+                connection()
+                        .buildRequest(
+                                userMessage(), List.of(), 
params("gpt-3.5-turbo"), Person.class);
+
+        assertThat(params.responseFormat()).isEmpty();
+    }
+
+    @Test
+    @DisplayName("Native NOT applied for a pre-cutoff same-family gpt-4o 
snapshot")
+    void testNativeNotAppliedForPreCutoffSnapshot() {
+        // gpt-4o-2024-05-13 predates the Structured Outputs cutoff even 
though it shares the gpt-4o
+        // prefix; treating it as capable would fail silently at the provider.
+        ChatCompletionCreateParams params =
+                connection()
+                        .buildRequest(
+                                userMessage(),
+                                List.of(),
+                                params("gpt-4o-2024-05-13"),
+                                Person.class);
+
+        assertThat(params.responseFormat()).isEmpty();
+    }
+
+    @Test
+    @DisplayName("Native NOT applied when no output schema is supplied")
+    void testNativeNotAppliedWhenSchemaNull() {
+        ChatCompletionCreateParams params =
+                connection().buildRequest(userMessage(), List.of(), 
params("gpt-4o"), null);
+
+        assertThat(params.responseFormat()).isEmpty();
+    }
+
+    @Test
+    @DisplayName("Native NOT applied for a non-POJO schema form (POJO-only 
scope)")
+    void testNativeNotAppliedForNonPojoSchema() {
+        // A RowTypeInfo schema arrives wrapped in OutputSchema (not a bare 
POJO Class), so it must
+        // not activate native structured output; any non-Class schema object 
exercises the same
+        // instanceof gate.
+        Object nonClassSchema = "row<name STRING>";
+
+        ChatCompletionCreateParams params =
+                connection()
+                        .buildRequest(userMessage(), List.of(), 
params("gpt-4o"), nonClassSchema);
+
+        assertThat(params.responseFormat()).isEmpty();
+    }
+
+    @Test
+    @DisplayName("Native applied for a POJO even when tools are bound (no 
empty-tools gate)")
+    void testNativeAppliedEvenWhenToolsBound() {
+        ChatCompletionCreateParams params =
+                connection()
+                        .buildRequest(
+                                userMessage(),
+                                List.of(new StubTool()),
+                                params("gpt-4o"),
+                                Person.class);
+
+        assertThat(params.responseFormat()).isPresent();
+    }
+
+    @Test
+    @DisplayName("Capability predicate accepts the documented capable models")
+    void testCapabilityPredicateAcceptsCapableModels() {
+        OpenAICompletionsConnection connection = connection();
+
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-2024-08-06")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-2024-11-20")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-mini")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-mini-2024-07-18")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-search-preview")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-search-preview-2025-03-11"))
+                .isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-mini-search-preview"))
+                .isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4.1")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4.1-mini")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-5")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-5-mini")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-5-chat-latest")).isTrue();
+        assertThat(connection.supportsNativeStructuredOutput("o1")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("o1-2024-12-17")).isTrue();
+        assertThat(connection.supportsNativeStructuredOutput("o3")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("o3-mini")).isTrue();
+        
assertThat(connection.supportsNativeStructuredOutput("o4-mini")).isTrue();
+    }
+
+    @Test
+    @DisplayName(
+            "Capability predicate rejects non-text modality, incapable, 
pre-cutoff, unknown, empty,"
+                    + " and null models")
+    void testCapabilityPredicateRejectsIncapableModels() {
+        OpenAICompletionsConnection connection = connection();
+
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-3.5-turbo")).isFalse();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4")).isFalse();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4-turbo")).isFalse();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-2024-05-13")).isFalse();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-audio-preview")).isFalse();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-mini-audio-preview"))
+                .isFalse();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-mini-realtime-preview"))
+                .isFalse();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-mini-tts")).isFalse();
+        
assertThat(connection.supportsNativeStructuredOutput("gpt-4o-mini-transcribe")).isFalse();
+        
assertThat(connection.supportsNativeStructuredOutput("o1-mini")).isFalse();
+        
assertThat(connection.supportsNativeStructuredOutput("some-unknown-model")).isFalse();
+        assertThat(connection.supportsNativeStructuredOutput("")).isFalse();
+        assertThat(connection.supportsNativeStructuredOutput(null)).isFalse();
+    }
+
+    /** Minimal tool stub; only its presence in the tools list matters. */
+    private static class StubTool extends Tool {
+        StubTool() {
+            super(new ToolMetadata("add", "adds", "{\"type\":\"object\"}"));
+        }
+
+        @Override
+        public ToolType getToolType() {
+            return ToolType.FUNCTION;
+        }
+
+        @Override
+        public ToolResponse call(ToolParameters parameters) {
+            return ToolResponse.success(null);
+        }
+    }
+}
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 68c2414b..381098b0 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
@@ -19,7 +19,13 @@ from typing import Any, Dict, List, Literal, Sequence
 
 import httpx
 from openai import NOT_GIVEN, OpenAI
-from pydantic import Field, PrivateAttr
+
+# Private SDK module (leading underscore): the openai client itself uses this 
helper to
+# build the strict json_schema for response_format, and there is no public 
re-export. It
+# has existed at this path since the structured-output support in openai 
1.66.3 (the
+# pinned minimum). A future openai bump that moves it will fail loudly on 
import here.
+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
@@ -38,6 +44,65 @@ from 
flink_agents.integrations.chat_models.openai.openai_utils import (
 
 DEFAULT_OPENAI_MODEL = "gpt-4o-mini"
 
+# Models with documented json_schema strict Structured Outputs support. Source 
of
+# truth: https://platform.openai.com/docs/guides/structured-outputs
+#
+# A name carrying a non-text modality marker is rejected before anything else. 
Audio,
+# realtime, speech and transcription variants expose no json_schema response 
format
+# even though they share the name prefix of a capable text family, so the 
marker check
+# has to win over a prefix match rather than merely coexist with it.
+#
+# A text family whose entire lifetime post-dates the Structured Outputs cutoff 
is
+# matched by name prefix, so its dated snapshots and size variants resolve 
without
+# enumerating each one.
+#
+# Two cases cannot use a prefix and are matched exactly instead. The gpt-4o 
family
+# straddles the cutoff -- gpt-4o-2024-05-13 predates it and is not capable -- 
so the
+# boundary there is temporal rather than nominal. The o1 family is not 
uniform: o1 is
+# capable while o1-mini is not, so an "o1" prefix would admit an incapable 
sibling.
+#
+# A name outside every listed family reports not-capable and degrades to the 
prompt
+# fallback rather than failing at the provider. Within a listed family the 
prefix
+# assumes capability, so a family variant that ships without json_schema 
support has
+# to be excluded explicitly, either by a marker that appears in no capable 
name or by
+# replacing the family prefix with exact names.
+_NON_TEXT_MODALITY_MARKERS = ("-audio", "-realtime", "-tts", "-transcribe")
+_NATIVE_STRUCTURED_OUTPUT_FAMILY_PREFIXES = (
+    "gpt-4o-mini",
+    "gpt-4o-search-preview",
+    "gpt-4.1",
+    "gpt-5",
+    "o3",
+    "o4-mini",
+)
+_NATIVE_STRUCTURED_OUTPUT_MODELS = frozenset(
+    {"gpt-4o", "gpt-4o-2024-08-06", "gpt-4o-2024-11-20", "o1", "o1-2024-12-17"}
+)
+
+
+def _native_response_format(output_schema: Any) -> Dict[str, Any] | None:
+    """Build the OpenAI ``response_format`` for a native structured-output 
request.
+
+    Returns ``None`` (leaving behavior unchanged) unless the schema is a 
``BaseModel``
+    subclass. A ``RowTypeInfo`` schema is skipped so it keeps the 
prompt-engineering
+    fallback.
+    """
+    if output_schema is None:
+        return None
+    model = (
+        output_schema.output_schema if isinstance(output_schema, OutputSchema) 
else None
+    )
+    if not (isinstance(model, type) and issubclass(model, BaseModel)):
+        return None
+    return {
+        "type": "json_schema",
+        "json_schema": {
+            "name": model.__name__,
+            "schema": to_strict_json_schema(model),
+            "strict": True,
+        },
+    }
+
 
 class OpenAIChatModelConnection(BaseChatModelConnection):
     """The connection to the OpenAI LLM.
@@ -135,6 +200,25 @@ class OpenAIChatModelConnection(BaseChatModelConnection):
             "http_client": self._http_client,
         }
 
+    @override
+    def supports_native_structured_output(self, effective_model: str | None) 
-> bool:
+        """Whether OpenAI documents json_schema strict support for 
``effective_model``.
+
+        See the module-level allowlist for the source of truth and the 
rationale for
+        rejecting non-text modality variants, matching capable text families 
by prefix,
+        and matching the gpt-4o snapshots and the o1 names exactly. A name 
outside
+        every listed family reports ``False`` so it degrades to the 
prompt-engineering
+        fallback rather than failing at the provider.
+        """
+        if not effective_model:
+            return False
+        if any(marker in effective_model for marker in 
_NON_TEXT_MODALITY_MARKERS):
+            return False
+        return (
+            
effective_model.startswith(_NATIVE_STRUCTURED_OUTPUT_FAMILY_PREFIXES)
+            or effective_model in _NATIVE_STRUCTURED_OUTPUT_MODELS
+        )
+
     def chat(
         self,
         messages: Sequence[ChatMessage],
@@ -151,10 +235,10 @@ class OpenAIChatModelConnection(BaseChatModelConnection):
         tools : Optional[List]
             List of tools that can be called by the model
         output_schema : OutputSchema | None
-            Rejected when non-``None``: this connection has no native 
structured-output
-            translation, so callers stay on the prompt-engineering fallback. 
Declaring
-            the parameter keeps a caller-supplied schema out of ``**kwargs``, 
which is
-            forwarded to the provider SDK.
+            The schema the response should conform to, or ``None`` for an 
unconstrained
+            response. Native structured output is applied only for a 
``BaseModel``
+            schema on a model the provider documents as capable; a 
``RowTypeInfo``
+            schema or an incapable model keeps the prompt-engineering fallback.
         **kwargs : Any
             Additional parameters passed to the model service (e.g., 
temperature,
             max_tokens, etc.)
@@ -164,7 +248,6 @@ class OpenAIChatModelConnection(BaseChatModelConnection):
         ChatMessage
             Model response message
         """
-        self._reject_unsupported_output_schema(output_schema)
         tool_specs = None
         if tools is not None:
             tool_specs = [to_openai_tool(metadata=tool.metadata) for tool in 
tools]
@@ -174,6 +257,19 @@ class OpenAIChatModelConnection(BaseChatModelConnection):
                     tool_spec["function"]["strict"] = strict
                     
tool_spec["function"]["parameters"]["additionalProperties"] = False
 
+        # TODO(#912): the requested strategy is not visible here, so this check
+        # cannot tell an explicit NATIVE request apart from one that merely
+        # resolved to native. A caller asking for NATIVE on a model this
+        # predicate rejects therefore gets an unconstrained response instead of
+        # an error. Once strategy resolution is wired up, NATIVE must either
+        # bypass this capability check or fail explicitly.
+        if output_schema is not None and 
self.supports_native_structured_output(
+            kwargs.get("model")
+        ):
+            response_format = _native_response_format(output_schema)
+            if response_format is not None:
+                kwargs["response_format"] = response_format
+
         response = self.client.chat.completions.create(
             messages=convert_to_openai_messages(messages),
             tools=tool_specs or NOT_GIVEN,
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
new file mode 100644
index 00000000..f3149249
--- /dev/null
+++ 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_native_structured_output.py
@@ -0,0 +1,204 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+#################################################################################
+from typing import Any
+from unittest.mock import MagicMock
+
+import pytest
+from pydantic import BaseModel
+from pyflink.common.typeinfo import Types
+
+from flink_agents.api.agents.types import OutputSchema
+from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.integrations.chat_models.openai.openai_chat_model import (
+    OpenAIChatModelConnection,
+)
+from flink_agents.plan.function import PythonFunction
+from flink_agents.plan.tools.function_tool import FunctionTool
+
+
+class Person(BaseModel):
+    """A representative BaseModel output schema."""
+
+    name: str
+    age: int
+
+
+def _connection() -> OpenAIChatModelConnection:
+    conn = OpenAIChatModelConnection(
+        name="openai", api_key="test-key", api_base_url="http://localhost";
+    )
+    mock_client = MagicMock()
+    mock_message = MagicMock()
+    mock_message.role = "assistant"
+    mock_message.content = "ok"
+    mock_message.tool_calls = None
+    mock_client.chat.completions.create.return_value.choices = [
+        MagicMock(message=mock_message)
+    ]
+    mock_client.chat.completions.create.return_value.usage = None
+    conn._client = mock_client
+    return conn
+
+
+def _create_call_kwargs(conn: OpenAIChatModelConnection) -> dict[str, Any]:
+    return conn.client.chat.completions.create.call_args.kwargs
+
+
+def _add(a: int, b: int) -> int:
+    """Add two integers.
+
+    Parameters
+    ----------
+    a : int
+        first
+    b : int
+        second
+
+    Returns:
+    -------
+    int
+        sum
+    """
+    return a + b
+
+
+def test_native_applied_for_basemodel_capable_model() -> None:
+    """response_format json_schema strict applied for a BaseModel on a capable 
model."""
+    conn = _connection()
+    conn.chat(
+        [ChatMessage(role=MessageRole.USER, content="hi")],
+        model="gpt-4o",
+        output_schema=OutputSchema(output_schema=Person),
+    )
+    response_format = _create_call_kwargs(conn)["response_format"]
+    assert response_format["type"] == "json_schema"
+    assert response_format["json_schema"]["strict"] is True
+    assert response_format["json_schema"]["schema"]["additionalProperties"] is 
False
+
+
+def test_native_not_applied_for_incapable_model() -> None:
+    """Native NOT applied for a BaseModel on an incapable model (prompt 
fallback)."""
+    conn = _connection()
+    conn.chat(
+        [ChatMessage(role=MessageRole.USER, content="hi")],
+        model="gpt-3.5-turbo",
+        output_schema=OutputSchema(output_schema=Person),
+    )
+    assert "response_format" not in _create_call_kwargs(conn)
+
+
+def test_native_not_applied_for_pre_cutoff_snapshot() -> None:
+    """Native NOT applied for a pre-cutoff same-family gpt-4o snapshot.
+
+    gpt-4o-2024-05-13 predates the Structured Outputs cutoff even though it 
shares the
+    gpt-4o prefix; treating it as capable would fail silently at the provider.
+    """
+    conn = _connection()
+    conn.chat(
+        [ChatMessage(role=MessageRole.USER, content="hi")],
+        model="gpt-4o-2024-05-13",
+        output_schema=OutputSchema(output_schema=Person),
+    )
+    assert "response_format" not in _create_call_kwargs(conn)
+
+
+def test_native_not_applied_when_schema_none() -> None:
+    """Native NOT applied when no output schema is supplied."""
+    conn = _connection()
+    conn.chat(
+        [ChatMessage(role=MessageRole.USER, content="hi")],
+        model="gpt-4o",
+        output_schema=None,
+    )
+    assert "response_format" not in _create_call_kwargs(conn)
+
+
+def test_native_not_applied_for_row_type_info() -> None:
+    """Native NOT applied for a RowTypeInfo schema (BaseModel-only scope)."""
+    conn = _connection()
+    row_type = Types.ROW_NAMED(["name"], [Types.STRING()])
+    conn.chat(
+        [ChatMessage(role=MessageRole.USER, content="hi")],
+        model="gpt-4o",
+        output_schema=OutputSchema(output_schema=row_type),
+    )
+    assert "response_format" not in _create_call_kwargs(conn)
+
+
+def test_native_applied_even_when_tools_bound() -> None:
+    """Native applied for a BaseModel even when tools are bound (no 
empty-tools gate)."""
+    conn = _connection()
+    tool = FunctionTool(func=PythonFunction.from_callable(_add))
+    conn.chat(
+        [ChatMessage(role=MessageRole.USER, content="hi")],
+        tools=[tool],
+        model="gpt-4o",
+        output_schema=OutputSchema(output_schema=Person),
+    )
+    assert "response_format" in _create_call_kwargs(conn)
+
+
[email protected](
+    "model",
+    [
+        "gpt-4o",
+        "gpt-4o-2024-08-06",
+        "gpt-4o-2024-11-20",
+        "gpt-4o-mini",
+        "gpt-4o-mini-2024-07-18",
+        "gpt-4o-search-preview",
+        "gpt-4o-search-preview-2025-03-11",
+        "gpt-4o-mini-search-preview",
+        "gpt-4.1",
+        "gpt-4.1-mini",
+        "gpt-5",
+        "gpt-5-mini",
+        "gpt-5-chat-latest",
+        "o1",
+        "o1-2024-12-17",
+        "o3",
+        "o3-mini",
+        "o4-mini",
+    ],
+)
+def test_capability_predicate_accepts_capable_models(model: str) -> None:
+    """The capability predicate accepts the documented capable models."""
+    assert _connection().supports_native_structured_output(model) is True
+
+
[email protected](
+    "model",
+    [
+        "gpt-3.5-turbo",
+        "gpt-4",
+        "gpt-4-turbo",
+        "gpt-4o-2024-05-13",
+        "gpt-4o-audio-preview",
+        "gpt-4o-mini-audio-preview",
+        "gpt-4o-mini-realtime-preview",
+        "gpt-4o-mini-tts",
+        "gpt-4o-mini-transcribe",
+        "o1-mini",
+        "some-unknown-model",
+        "",
+        None,
+    ],
+)
+def test_capability_predicate_rejects_incapable_models(model: str | None) -> 
None:
+    """The predicate rejects modality variants, incapable, unknown, and empty 
models."""
+    assert _connection().supports_native_structured_output(model) is False
diff --git 
a/python/flink_agents/integrations/chat_models/tests/test_output_schema_param_declared.py
 
b/python/flink_agents/integrations/chat_models/tests/test_output_schema_param_declared.py
index e31d3753..9b814c3c 100644
--- 
a/python/flink_agents/integrations/chat_models/tests/test_output_schema_param_declared.py
+++ 
b/python/flink_agents/integrations/chat_models/tests/test_output_schema_param_declared.py
@@ -87,6 +87,21 @@ def _connections_implementing_chat() -> 
List[Type[BaseChatModelConnection]]:
     ]
 
 
+def _translates_schema_natively(cls: Type[BaseChatModelConnection]) -> bool:
+    """Whether ``cls`` applies an output schema through a native provider 
parameter.
+
+    Overriding ``supports_native_structured_output`` is how a connection 
reports that
+    capability, so the same override marks the connections that must accept a 
schema
+    instead of rejecting it. Such a connection owns the decision of what to do 
with a
+    schema it cannot apply natively for the effective model, and its own tests 
pin
+    that behavior.
+    """
+    return (
+        cls.supports_native_structured_output
+        is not BaseChatModelConnection.supports_native_structured_output
+    )
+
+
 def test_every_connection_declares_output_schema_param() -> None:
     """Every connection in the tree must declare ``output_schema`` defaulting 
to None.
 
@@ -147,13 +162,20 @@ def 
test_every_connection_rejects_an_output_schema_it_cannot_translate() -> None
     own it lets a connection silently return an unconstrained response that 
the caller
     would treat as schema-conforming. Rejecting turns that into an error at 
the call.
 
+    A connection that does translate a schema natively is exempt, since 
rejecting is
+    exactly what it must not do.
+
     The tree is walked rather than hand-listed, so a connection added to a 
module that
     is already imported is held to the contract for free. Reach still stops at 
those
     imports: ``__subclasses__()`` only sees classes that have been imported, 
so a
     connection in a brand-new module needs that module added at the top of 
this file.
     """
     schema = OutputSchema(output_schema=_Answer)
-    connections = _connections_implementing_chat()
+    connections = [
+        cls
+        for cls in _connections_implementing_chat()
+        if not _translates_schema_natively(cls)
+    ]
 
     assert connections, "the connection walk found nothing to check"
     offenders = [

Reply via email to