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 31c0259a [api][java][python] Name the model the capability predicate
judges (#1128)
31c0259a is described below
commit 31c0259aace0f9123036946a0c38a3e34bd32e51
Author: Weiqing Yang <[email protected]>
AuthorDate: Wed Sep 16 20:18:53 2026 -0700
[api][java][python] Name the model the capability predicate judges (#1128)
Generated-by: Claude Code 2.1.272 (Claude Opus 5)
---
.../api/chat/model/BaseChatModelConnection.java | 46 +++++++--
.../agents/api/chat/model/BaseChatModelTest.java | 28 ++++++
.../anthropic/AnthropicChatModelConnection.java | 14 +++
.../AnthropicChatModelConnectionTest.java | 43 +++++++++
.../bedrock/BedrockChatModelConnection.java | 16 ++++
.../bedrock/BedrockChatModelConnectionTest.java | 56 +++++++++++
.../gemini/GeminiChatModelConnection.java | 17 ++++
.../gemini/GeminiChatModelConnectionTest.java | 70 ++++++++++++++
.../openai/AzureOpenAIChatModelConnection.java | 14 +++
.../openai/OpenAICompletionsConnection.java | 13 +++
.../openai/OpenAIResponsesModelConnection.java | 13 +++
.../openai/AzureOpenAIChatModelConnectionTest.java | 63 +++++++++++++
.../openai/OpenAICompletionsConnectionTest.java | 54 +++++++++++
.../openai/OpenAIResponsesModelConnectionTest.java | 12 +++
python/flink_agents/api/chat_models/chat_model.py | 52 ++++++++--
.../api/chat_models/tests/test_chat_model_base.py | 53 +++++++++++
.../tests/test_anthropic_response_parsing.py | 56 +++++++++++
.../chat_models/azure/azure_openai_chat_model.py | 17 +++-
.../test_azure_openai_native_structured_output.py | 105 +++++++++++++++++++++
.../tests/test_openai_native_structured_output.py | 56 +++++++++++
.../tests/test_ollama_native_structured_output.py | 64 +++++++++++++
.../tests/test_tongyi_native_structured_output.py | 89 +++++++++++++++++
.../integrations/chat_models/tongyi_chat_model.py | 18 +++-
.../tests/test_watsonx_native_structured_output.py | 95 +++++++++++++++++++
.../chat_models/watsonx/watsonx_chat_model.py | 18 +++-
25 files changed, 1065 insertions(+), 17 deletions(-)
diff --git
a/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelConnection.java
b/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelConnection.java
index 57d0a93b..79bb5ed6 100644
---
a/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelConnection.java
+++
b/api/src/main/java/org/apache/flink/agents/api/chat/model/BaseChatModelConnection.java
@@ -52,21 +52,53 @@ public abstract class BaseChatModelConnection extends
Resource {
* model.
*
* <p>Capability is <b>model-dependent</b>, not connection-wide: a single
provider connection
- * commonly serves both models that accept a native schema parameter and
models that do not. It
- * is therefore evaluated against the <i>effective</i> model at
request-build time — the model
- * actually being called, which per-request parameters may override.
+ * commonly serves both models that accept a native schema parameter and
models that do not. The
+ * model to ask about is whatever {@link #effectiveModelFor(Map)} returns
for the parameters a
+ * request would be built from, and a caller outside the connection asks
that hook and nothing
+ * else. Such a caller must not substitute the identifier the request is
issued against: on a
+ * deployment-based provider the request targets a deployment name the
user chose while
+ * capability belongs to the model backing it, so the two disagree in both
directions.
*
- * <p>The default {@code false} keeps a connection on the
prompt-engineering fallback. An
- * unrecognized model must report {@code false} so that it degrades to the
fallback rather than
- * failing at the provider.
+ * <p>The default {@code false} keeps a connection on the
prompt-engineering fallback. A
+ * connection that classifies by model name must report {@code false} for
a name it does not
+ * recognize, so that it degrades to the fallback rather than failing at
the provider. A
+ * connection whose capability belongs to the endpoint rather than to the
model answers for the
+ * endpoint instead, and may report {@code true} for a name it has never
seen.
*
- * @param effectiveModel the model the request will be issued against, may
be null
+ * @param effectiveModel the model whose capability is being asked about,
as returned by {@link
+ * #effectiveModelFor(Map)}, may be null
* @return true if a schema can be applied natively for {@code
effectiveModel}
*/
protected boolean supportsNativeStructuredOutput(String effectiveModel) {
return false;
}
+ /**
+ * The model whose capability {@link
#supportsNativeStructuredOutput(String)} should be asked
+ * about, derived from the parameters a request would be built from.
+ *
+ * <p>Overriding this is how a connection whose effective model is not the
{@code model}
+ * parameter verbatim keeps the capability answer and the request in
agreement. A connection
+ * that falls back to a configured default model when the parameter is
absent applies that
+ * fallback here, and a deployment-based provider returns the model
backing the deployment
+ * rather than the deployment the request targets.
+ *
+ * <p>Answers for whatever it is given rather than validating: a model
that does not resolve
+ * comes back null, and {@link #supportsNativeStructuredOutput(String)}
must accept null rather
+ * than throwing. What a null model resolves to is that predicate's own
answer; the default, and
+ * every override that classifies by name, reports it not capable.
+ *
+ * <p>An override must read {@code modelParams} without consuming it, so
that the same map still
+ * builds the request the answer was about.
+ *
+ * @param modelParams the parameters a request would be built from, may be
null
+ * @return the model to ask the capability predicate about, or null if
none resolves
+ */
+ @Nullable
+ protected String effectiveModelFor(@Nullable Map<String, Object>
modelParams) {
+ return modelParams == null ? null : (String) modelParams.get("model");
+ }
+
/**
* Process a chat request and return a chat response.
*
diff --git
a/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelTest.java
b/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelTest.java
index d27d79cb..bb030f77 100644
---
a/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelTest.java
+++
b/api/src/test/java/org/apache/flink/agents/api/chat/model/BaseChatModelTest.java
@@ -460,4 +460,32 @@ class BaseChatModelTest {
assertNotNull(response);
assertTrue(response.getContent().length() > 0);
}
+
+ @Test
+ @DisplayName("Effective model defaults to the model parameter a request
would be built from")
+ void testEffectiveModelForReadsTheModelParameter() {
+ RecordingConnection connection = new RecordingConnection();
+ Map<String, Object> modelParams = new HashMap<>();
+ modelParams.put("model", "gpt-4o");
+
+ assertEquals("gpt-4o", connection.effectiveModelFor(modelParams));
+ }
+
+ @Test
+ @DisplayName("Effective model is null when the parameters name no model")
+ void testEffectiveModelForReturnsNullWhenModelParameterAbsent() {
+ RecordingConnection connection = new RecordingConnection();
+
+ // A connection carrying no default of its own has no model to
resolve, and the capability
+ // predicate reports a null model not capable rather than throwing.
+ assertNull(connection.effectiveModelFor(Map.of("temperature", 0.5)));
+ }
+
+ @Test
+ @DisplayName("Effective model is null for null parameters")
+ void testEffectiveModelForReturnsNullForNullParameters() {
+ RecordingConnection connection = new RecordingConnection();
+
+ assertNull(connection.effectiveModelFor(null));
+ }
}
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 8b613b02..116b3ad1 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
@@ -182,6 +182,20 @@ public class AnthropicChatModelConnection extends
BaseChatModelConnection {
||
effectiveModel.startsWith(prefix + "-"));
}
+ /**
+ * The {@code model} parameter, falling back to the model configured on
the connection when the
+ * call names none, which is how the request itself resolves the model it
is issued against.
+ */
+ @Override
+ protected String effectiveModelFor(Map<String, Object> modelParams) {
+ Object modelObj = modelParams != null ? modelParams.get("model") :
null;
+ String modelName = modelObj != null ? modelObj.toString() : null;
+ if (modelName == null || modelName.isBlank()) {
+ return this.defaultModel;
+ }
+ return modelName;
+ }
+
// Models Anthropic documents as rejecting assistant-message prefilling.
Source of truth:
//
https://platform.claude.com/docs/en/build-with-claude/working-with-messages#putting-words-in-claudes-mouth
//
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 daeb1084..0246161b 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
@@ -396,6 +396,49 @@ class AnthropicChatModelConnectionTest {
assertThat(build(CAPABLE_MODEL, null,
null).params.outputConfig()).isEmpty();
}
+ @Test
+ @DisplayName("the effective model falls back to the configured default")
+ void testEffectiveModelForFallsBackToTheConfiguredDefault() {
+ // buildRequest resolves the model the same way before feeding the
capability predicate,
+ // and connection() is configured with claude-sonnet-4-20250514.
+ assertThat(connection().effectiveModelFor(params(null)))
+ .isEqualTo("claude-sonnet-4-20250514");
+
assertThat(connection().effectiveModelFor(paramsWithModel(CAPABLE_MODEL, null)))
+ .isEqualTo(CAPABLE_MODEL);
+ // A blank model is not a model. The builder substitutes the default
for it, so the hook
+ // has to as well or the two disagree on exactly this input.
+ assertThat(connection().effectiveModelFor(paramsWithModel(" ",
null)))
+ .isEqualTo("claude-sonnet-4-20250514");
+ }
+
+ @Test
+ @DisplayName("the model the request builder judges is the one the hook
names")
+ void testEffectiveModelForNamesTheModelTheBuilderJudges() {
+ // The hook duplicates the builder's resolution rather than
centralizing it, so only
+ // capturing what the builder feeds the predicate keeps the two from
drifting apart.
+ AtomicReference<String> judged = new AtomicReference<>();
+ AnthropicChatModelConnection connection =
+ new
AnthropicChatModelConnection(descriptor("claude-sonnet-4-20250514"), NOOP) {
+ @Override
+ protected boolean supportsNativeStructuredOutput(String
effectiveModel) {
+ judged.set(effectiveModel);
+ return
super.supportsNativeStructuredOutput(effectiveModel);
+ }
+ };
+
+ for (Map<String, Object> modelParams :
+ List.<Map<String, Object>>of(
+ paramsWithModel(CAPABLE_MODEL, null),
+ paramsWithModel(" ", null),
+ params(null))) {
+ String named = connection.effectiveModelFor(modelParams);
+
+ connection.buildRequest(userMessage(), List.of(), modelParams,
Answer.class);
+
+ assertThat(judged.get()).isEqualTo(named);
+ }
+ }
+
@Test
@DisplayName("a schema that is not a Class keeps the prompt fallback")
void testNonClassSchemaKeepsFallback() {
diff --git
a/integrations/chat-models/bedrock/src/main/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnection.java
b/integrations/chat-models/bedrock/src/main/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnection.java
index 29989ab6..97ca7fd7 100644
---
a/integrations/chat-models/bedrock/src/main/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnection.java
+++
b/integrations/chat-models/bedrock/src/main/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnection.java
@@ -237,6 +237,22 @@ public class BedrockChatModelConnection extends
BaseChatModelConnection {
return profile.matches() &&
NATIVE_STRUCTURED_OUTPUT_MODELS.contains(profile.group(1));
}
+ /**
+ * The {@code model} parameter, falling back to the model configured on
the connection when the
+ * call names none, which is how the request itself resolves the model it
is issued against.
+ *
+ * <p>Resolving to nothing comes back null rather than raising the way
{@code resolveModel}
+ * does, because the capability predicate reports a null model not capable.
+ */
+ @Override
+ protected String effectiveModelFor(Map<String, Object> modelParams) {
+ String model = modelParams != null ? (String) modelParams.get("model")
: null;
+ if (model == null || model.isBlank()) {
+ return this.defaultModel;
+ }
+ return model;
+ }
+
@Override
public ChatMessage chat(
List<ChatMessage> messages, List<Tool> tools, Map<String, Object>
modelParams) {
diff --git
a/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java
b/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java
index 334c94a7..3cd315c9 100644
---
a/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java
+++
b/integrations/chat-models/bedrock/src/test/java/org/apache/flink/agents/integrations/chatmodels/bedrock/BedrockChatModelConnectionTest.java
@@ -46,6 +46,7 @@ import
software.amazon.awssdk.services.bedrockruntime.model.Message;
import software.amazon.awssdk.services.bedrockruntime.model.OutputFormatType;
import java.util.*;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
@@ -130,6 +131,61 @@ class BedrockChatModelConnectionTest {
.isInstanceOf(RuntimeException.class);
}
+ @Test
+ @DisplayName("the effective model falls back to the configured default")
+ void testEffectiveModelForFallsBackToTheConfiguredDefault() {
+ // buildRequest resolves the model the same way before feeding the
capability predicate.
+ assertThat(connection().effectiveModelFor(new HashMap<>()))
+ .isEqualTo("us.anthropic.claude-sonnet-4-20250514-v1:0");
+
assertThat(connection().effectiveModelFor(params("qwen.qwen3-32b-v1:0")))
+ .isEqualTo("qwen.qwen3-32b-v1:0");
+ // A blank model is not a model. resolveModel substitutes the default
for it, so the hook
+ // has to as well or the two disagree on exactly this input.
+ assertThat(connection().effectiveModelFor(params(" ")))
+ .isEqualTo("us.anthropic.claude-sonnet-4-20250514-v1:0");
+ }
+
+ @Test
+ @DisplayName("the model the request builder judges is the one the hook
names")
+ void testEffectiveModelForNamesTheModelTheBuilderJudges() {
+ // The hook duplicates resolveModel rather than calling it, so only
capturing what the
+ // builder feeds the predicate keeps the two from drifting apart.
+ AtomicReference<String> judged = new AtomicReference<>();
+ BedrockChatModelConnection connection =
+ new BedrockChatModelConnection(
+ descriptor("us-east-1",
"us.anthropic.claude-sonnet-4-20250514-v1:0"),
+ NOOP) {
+ @Override
+ protected boolean supportsNativeStructuredOutput(String
effectiveModel) {
+ judged.set(effectiveModel);
+ return
super.supportsNativeStructuredOutput(effectiveModel);
+ }
+ };
+
+ for (Map<String, Object> modelParams :
+ List.<Map<String, Object>>of(
+ params("qwen.qwen3-32b-v1:0"), params(" "), new
HashMap<>())) {
+ String named = connection.effectiveModelFor(modelParams);
+
+ connection.buildRequest(
+ List.of(ChatMessage.user("hello")), null, modelParams,
Profile.class);
+
+ assertThat(judged.get()).isEqualTo(named);
+ }
+ }
+
+ @Test
+ @DisplayName("the effective model is null rather than throwing when none
resolves")
+ void testEffectiveModelForReturnsNullWhenNoModelResolves() {
+ // resolveModel rejects an unresolvable model before a request is
built. This query is
+ // part of the connection contract and answers for whatever it is
given, so it reports
+ // null, which the capability predicate treats as not capable.
+ BedrockChatModelConnection conn =
+ new BedrockChatModelConnection(descriptor("us-east-1", null),
NOOP);
+
+ assertThat(conn.effectiveModelFor(new HashMap<>())).isNull();
+ }
+
@Test
@DisplayName("chat accepts a POJO output schema instead of rejecting it as
untranslatable")
void testChatAcceptsPojoOutputSchema() {
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 3c34c431..1f72f524 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
@@ -234,6 +234,23 @@ public class GeminiChatModelConnection extends
BaseChatModelConnection {
&& effectiveModel.length() >
NATIVE_STRUCTURED_OUTPUT_FAMILY_PREFIX.length();
}
+ /**
+ * The {@code model} parameter, falling back to the model configured on
the connection when the
+ * call names none, which is how the request itself resolves the model it
is issued against.
+ *
+ * <p>Resolving to nothing comes back null rather than raising the way
request building does,
+ * because the capability predicate reports a null model not capable.
+ */
+ @Override
+ protected String effectiveModelFor(Map<String, Object> modelParams) {
+ Object modelObj = modelParams != null ? modelParams.get("model") :
null;
+ String modelName = modelObj != null ? modelObj.toString() : null;
+ if (modelName == null || modelName.isBlank()) {
+ return this.defaultModel;
+ }
+ return modelName;
+ }
+
@Override
public ChatMessage chat(
List<ChatMessage> messages,
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 1cadda4e..ea27d4ca 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
@@ -53,6 +53,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -920,4 +921,73 @@ class GeminiChatModelConnectionTest {
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("model name must be provided");
}
+
+ @Test
+ @DisplayName("The effective model falls back to the configured default")
+ void effectiveModelForFallsBackToTheConfiguredDefault() {
+ // chat resolves the model the same way before feeding the capability
predicate, and
+ // connection() is configured with gemini-3-pro-preview.
+ Map<String, Object> modelParams = params();
+
+
assertThat(connection().effectiveModelFor(modelParams)).isEqualTo("gemini-3-pro-preview");
+
+ modelParams.put("model", CAPABLE_MODEL);
+
assertThat(connection().effectiveModelFor(modelParams)).isEqualTo(CAPABLE_MODEL);
+
+ // A blank model is not a model. The request path substitutes the
default for it, so the
+ // hook has to as well or the two disagree on exactly this input.
+ modelParams.put("model", " ");
+
assertThat(connection().effectiveModelFor(modelParams)).isEqualTo("gemini-3-pro-preview");
+ }
+
+ /** Stops the request after the config is assembled, so no call reaches
the provider. */
+ private static final class StopBeforeRequest extends RuntimeException {}
+
+ private static Map<String, Object> paramsWithModel(String model) {
+ Map<String, Object> modelParams = params();
+ modelParams.put("model", model);
+ return modelParams;
+ }
+
+ @Test
+ @DisplayName("The model the request builder judges is the one the hook
names")
+ void effectiveModelForNamesTheModelTheBuilderJudges() {
+ // chat resolves the model and hands it to buildConfig, which is where
the predicate is fed.
+ // Overriding buildConfig to stop once it has run binds the hook to
the request without
+ // reaching the provider. Asserting each side against a literal would
let the two drift in
+ // step, which is the one failure this has to catch.
+ AtomicReference<String> judged = new AtomicReference<>();
+ GeminiChatModelConnection connection =
+ new GeminiChatModelConnection(
+ descriptor("test-key", null, "gemini-3-pro-preview"),
NOOP) {
+ @Override
+ protected boolean supportsNativeStructuredOutput(String
effectiveModel) {
+ judged.set(effectiveModel);
+ return
super.supportsNativeStructuredOutput(effectiveModel);
+ }
+
+ @Override
+ GenerateContentConfig buildConfig(
+ List<ChatMessage> messages,
+ List<Tool> tools,
+ Map<String, Object> arguments,
+ String modelName,
+ Object outputSchema) {
+ super.buildConfig(messages, tools, arguments,
modelName, outputSchema);
+ throw new StopBeforeRequest();
+ }
+ };
+
+ for (Map<String, Object> modelParams :
+ List.<Map<String, Object>>of(
+ paramsWithModel(CAPABLE_MODEL), paramsWithModel("
"), params())) {
+ String named = connection.effectiveModelFor(modelParams);
+
+ assertThatThrownBy(
+ () -> connection.chat(userMessage(), null,
modelParams, Report.class))
+ .hasRootCauseInstanceOf(StopBeforeRequest.class);
+
+ assertThat(judged.get()).isEqualTo(named);
+ }
+ }
}
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 4ce122ad..440a7c8d 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
@@ -223,6 +223,20 @@ public class AzureOpenAIChatModelConnection extends
BaseChatModelConnection {
return NATIVE_STRUCTURED_OUTPUT_MODELS.contains(effectiveModel);
}
+ /**
+ * The model backing the deployment, read from {@code
model_of_azure_deployment}.
+ *
+ * <p>The {@code model} parameter carries the deployment name the request
is issued against.
+ * That name is chosen by the user, and although it commonly echoes the
model behind it, nothing
+ * keeps the two in step once the deployment is repointed, so it is never
the answer here.
+ * Leaving the backing model unset keeps even a capable deployment on the
prompt-engineering
+ * fallback rather than classifying a deployment name on its spelling.
+ */
+ @Override
+ protected String effectiveModelFor(Map<String, Object> modelParams) {
+ return modelParams == null ? null : (String)
modelParams.get("model_of_azure_deployment");
+ }
+
/**
* Whether the configured api-version reaches the structured-output floor.
*
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 4864769e..da8b2dc2 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
@@ -171,6 +171,19 @@ public class OpenAICompletionsConnection extends
BaseChatModelConnection {
|| NATIVE_STRUCTURED_OUTPUT_MODELS.contains(effectiveModel);
}
+ /**
+ * The {@code model} parameter, falling back to the model configured on
the connection when the
+ * call names none, which is how the request itself resolves the model it
is issued against.
+ */
+ @Override
+ protected String effectiveModelFor(Map<String, Object> modelParams) {
+ String modelName = modelParams != null ? (String)
modelParams.get("model") : null;
+ if (modelName == null || modelName.isBlank()) {
+ return this.defaultModel;
+ }
+ return modelName;
+ }
+
/**
* Returns the model response. When the provider reports a finish reason
it is carried verbatim
* in {@code extraArgs} under {@code finish_reason}, including values
outside the documented
diff --git
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIResponsesModelConnection.java
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIResponsesModelConnection.java
index 5dca0b6a..77d16c21 100644
---
a/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIResponsesModelConnection.java
+++
b/integrations/chat-models/openai/src/main/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIResponsesModelConnection.java
@@ -124,6 +124,19 @@ public class OpenAIResponsesModelConnection extends
BaseChatModelConnection {
this.client = builder.build();
}
+ /**
+ * The {@code model} parameter, falling back to the model configured on
the connection when the
+ * call names none, which is how the request itself resolves the model it
is issued against.
+ */
+ @Override
+ protected String effectiveModelFor(Map<String, Object> modelParams) {
+ String modelName = modelParams != null ? (String)
modelParams.get("model") : null;
+ if (modelName == null || modelName.isBlank()) {
+ return this.defaultModel;
+ }
+ return modelName;
+ }
+
@Override
public ChatMessage chat(
List<ChatMessage> messages,
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 24d448a7..f23cca2e 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
@@ -357,6 +357,69 @@ class AzureOpenAIChatModelConnectionTest {
assertThat(request.responseFormat()).isEmpty();
}
+ @Test
+ @DisplayName("Effective model is the model backing the deployment, not the
deployment")
+ void testEffectiveModelForReturnsBackingModelNotDeployment() {
+ // params() addresses DEPLOYMENT under "model". Capability belongs to
the model backing
+ // that deployment, which is why buildRequest feeds the predicate
+ // model_of_azure_deployment instead.
+
assertThat(connection().effectiveModelFor(params("gpt-4o-mini"))).isEqualTo("gpt-4o-mini");
+ }
+
+ @Test
+ @DisplayName(
+ "The model the request builder judges is the one the hook names,
never the deployment")
+ void testEffectiveModelForNamesTheModelTheBuilderJudges() {
+ // The builder reads the backing model under its own key and feeds
that to the predicate.
+ // Capturing the value it actually judges is what ties the hook to the
request; comparing
+ // each against a literal would let the two drift in step.
+ AtomicReference<String> judged = new AtomicReference<>();
+ ResourceDescriptor desc =
+ connectionDescriptor()
+ .addInitialArgument("api_key", "test-key")
+ .addInitialArgument("api_version", CAPABLE_API_VERSION)
+ .addInitialArgument("azure_endpoint",
"https://example.openai.azure.com")
+ .build();
+ AzureOpenAIChatModelConnection connection =
+ new AzureOpenAIChatModelConnection(desc, NOOP) {
+ @Override
+ protected boolean supportsNativeStructuredOutput(String
effectiveModel) {
+ judged.set(effectiveModel);
+ return
super.supportsNativeStructuredOutput(effectiveModel);
+ }
+ };
+
+ Map<String, Object> backed = params("gpt-4o-mini");
+ String named = connection.effectiveModelFor(backed);
+ connection.buildRequest(userMessage(), List.of(), backed,
Person.class);
+ assertThat(judged.get()).isEqualTo(named).isNotEqualTo(DEPLOYMENT);
+
+ // The unset case has to agree too: the builder judges nothing, and so
does the hook.
+ Map<String, Object> unbacked = params(null);
+ assertThat(connection.effectiveModelFor(unbacked)).isNull();
+ connection.buildRequest(userMessage(), List.of(), unbacked,
Person.class);
+ assertThat(judged.get()).isNull();
+ }
+
+ @Test
+ @DisplayName("Effective model reads the backing model without consuming
it")
+ void testEffectiveModelForDoesNotConsumeTheBackingModel() {
+ // The request builder removes this very key. An override copying that
idiom would hand the
+ // builder a map with no backing model, and the native branch would
silently disappear.
+ Map<String, Object> modelParams = params("gpt-4o-mini");
+
+
assertThat(connection().effectiveModelFor(modelParams)).isEqualTo("gpt-4o-mini");
+ assertThat(modelParams).containsEntry("model_of_azure_deployment",
"gpt-4o-mini");
+ }
+
+ @Test
+ @DisplayName("Effective model is null when the backing model is unset")
+ void testEffectiveModelForReturnsNullWhenBackingModelUnset() {
+ // Falling back to the deployment name would classify a user-chosen
name on nothing but
+ // its spelling, so an unset backing model resolves to nothing at all.
+ assertThat(connection().effectiveModelFor(params(null))).isNull();
+ }
+
@Test
@DisplayName("Native NOT applied for a backing model outside the
allowlist")
void testNativeNotAppliedForUnknownDeploymentModel() {
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 851e9004..76b6b0ac 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
@@ -344,6 +344,60 @@ class OpenAICompletionsConnectionTest {
assertThat(params.responseFormat()).isEmpty();
}
+ @Test
+ @DisplayName("Effective model falls back to the connection default when
the parameter is unset")
+ void testEffectiveModelForFallsBackToTheDefaultModel() {
+ // buildRequest applies the same fallback before feeding the
capability predicate, so an
+ // answer taken without it would disagree with the model the request
is issued against.
+ assertThat(connection().effectiveModelFor(new
HashMap<>())).isEqualTo("gpt-4o");
+
assertThat(connection().effectiveModelFor(params(null))).isEqualTo("gpt-4o");
+ assertThat(connection().effectiveModelFor(params("
"))).isEqualTo("gpt-4o");
+ }
+
+ @Test
+ @DisplayName("The model the request builder judges is the one the hook
names")
+ void testEffectiveModelForNamesTheModelTheBuilderJudges() {
+ // The hook duplicates the builder's own model resolution rather than
centralizing it, so
+ // capturing what the builder actually feeds the predicate is the only
thing that keeps the
+ // two from drifting apart. Asserting each against a literal would let
them drift in step.
+ AtomicReference<String> judged = new AtomicReference<>();
+ OpenAICompletionsConnection connection =
+ new OpenAICompletionsConnection(
+ ResourceDescriptor.Builder.newBuilder(
+
OpenAICompletionsConnection.class.getName())
+ .addInitialArgument("api_key", "test-key")
+ .addInitialArgument("model", "gpt-4o")
+ .build(),
+ NOOP) {
+ @Override
+ protected boolean supportsNativeStructuredOutput(String
effectiveModel) {
+ judged.set(effectiveModel);
+ return
super.supportsNativeStructuredOutput(effectiveModel);
+ }
+ };
+
+ for (Map<String, Object> modelParams :
+ List.<Map<String, Object>>of(
+ params("gpt-4o-mini"), params(" "), new
HashMap<>())) {
+ String named = connection.effectiveModelFor(modelParams);
+
+ connection.buildRequest(userMessage(), List.of(), modelParams,
Person.class);
+
+ assertThat(judged.get()).isEqualTo(named);
+ }
+ }
+
+ @Test
+ @DisplayName("Effective model reads the model parameter without consuming
it")
+ void testEffectiveModelForDoesNotConsumeTheModelParameter() {
+ // buildRequest resolves the model with remove(). A query doing the
same would leave the
+ // caller's parameters without a model for the request it is about to
build.
+ Map<String, Object> modelParams = params("gpt-4o-mini");
+
+
assertThat(connection().effectiveModelFor(modelParams)).isEqualTo("gpt-4o-mini");
+ assertThat(modelParams).containsEntry("model", "gpt-4o-mini");
+ }
+
@Test
@DisplayName("Native NOT applied for a non-POJO schema form (POJO-only
scope)")
void testNativeNotAppliedForNonPojoSchema() {
diff --git
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIResponsesModelConnectionTest.java
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIResponsesModelConnectionTest.java
index dfb8d625..ac018f2d 100644
---
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIResponsesModelConnectionTest.java
+++
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/OpenAIResponsesModelConnectionTest.java
@@ -79,6 +79,18 @@ class OpenAIResponsesModelConnectionTest {
return List.of(new ChatMessage(MessageRole.USER, "hi"));
}
+ @Test
+ @DisplayName("Effective model falls back to the connection default when
the parameter is unset")
+ void testEffectiveModelForFallsBackToTheDefaultModel() {
+ // This connection extends the base directly, so it inherits no
override from the chat
+ // completions connection. Its request builder resolves the model
against the configured
+ // default, and the capability answer has to be about that same model.
+ assertThat(connection().effectiveModelFor(new
HashMap<>())).isEqualTo("gpt-4o");
+
assertThat(connection().effectiveModelFor(params(null))).isEqualTo("gpt-4o");
+ assertThat(connection().effectiveModelFor(params("
"))).isEqualTo("gpt-4o");
+
assertThat(connection().effectiveModelFor(params("gpt-4o-mini"))).isEqualTo("gpt-4o-mini");
+ }
+
@Test
@DisplayName("Constructor throws when api_key is missing")
void testConstructorMissingApiKey() {
diff --git a/python/flink_agents/api/chat_models/chat_model.py
b/python/flink_agents/api/chat_models/chat_model.py
index 11cf6579..133e57ae 100644
--- a/python/flink_agents/api/chat_models/chat_model.py
+++ b/python/flink_agents/api/chat_models/chat_model.py
@@ -135,19 +135,25 @@ class BaseChatModelConnection(Resource, ABC):
Capability is *model-dependent*, not connection-wide: a single provider
connection commonly serves both models that accept a native schema
parameter and
- models that do not, so it is evaluated against the *effective* model at
- request-build time — the model actually being called, which per-request
- parameters may override.
+ models that do not. The model to ask about is whatever
``effective_model_for``
+ returns for the parameters a request would be built from, and a caller
outside
+ the connection asks that hook and nothing else. Such a caller must not
+ substitute the identifier the request is issued against: on a
deployment-based
+ provider the request targets a deployment name the user chose while
capability
+ belongs to the model backing it, so the two disagree in both
directions.
The default ``False`` keeps a connection on the prompt-engineering
fallback. A
- connection that translates a schema into a native provider parameter
overrides
- this; an unrecognized model must report ``False`` so it degrades to
the fallback
- rather than failing at the provider.
+ connection that classifies by model name must report ``False`` for a
name it
+ does not recognize, so that it degrades to the fallback rather than
failing at
+ the provider. A connection whose capability belongs to the endpoint
rather than
+ to the model answers for the endpoint instead, and may report ``True``
for a
+ name it has never seen.
Parameters
----------
effective_model : str | None
- The model the request will be issued against, may be ``None``.
+ The model whose capability is being asked about, as returned by
+ ``effective_model_for``, may be ``None``.
Returns:
-------
@@ -156,6 +162,38 @@ class BaseChatModelConnection(Resource, ABC):
"""
return False
+ def effective_model_for(self, model_kwargs: Mapping[str, Any] | None) ->
str | None:
+ """The model ``supports_native_structured_output`` should be asked
about.
+
+ Derived from the parameters a request would be built from. Overriding
this is
+ how a connection whose effective model is not the ``model`` parameter
verbatim
+ keeps the capability answer and the request in agreement: a connection
that
+ falls back to a configured default model when the parameter is absent
applies
+ that fallback here, and a deployment-based provider returns the model
backing
+ the deployment rather than the deployment the request targets.
+
+ Answers for whatever it is given rather than validating it: a model
that does
+ not resolve comes back ``None``, and
``supports_native_structured_output`` must
+ accept ``None`` rather than raising. What a ``None`` model resolves to
is that
+ predicate's own answer; the default, and every override that
classifies by
+ name, reports it not capable.
+
+ An override must read ``model_kwargs`` without consuming it, so that
the same
+ mapping still builds the request the answer was about.
+
+ Parameters
+ ----------
+ model_kwargs : Mapping[str, Any] | None
+ The parameters a request would be built from, may be ``None``.
+
+ Returns:
+ -------
+ str | None
+ The model to ask the capability predicate about, or ``None`` if
none
+ resolves.
+ """
+ return None if model_kwargs is None else model_kwargs.get("model")
+
def _reject_unsupported_output_schema(
self, output_schema: OutputSchema | None
) -> None:
diff --git a/python/flink_agents/api/chat_models/tests/test_chat_model_base.py
b/python/flink_agents/api/chat_models/tests/test_chat_model_base.py
index a7524973..cd6417ee 100644
--- a/python/flink_agents/api/chat_models/tests/test_chat_model_base.py
+++ b/python/flink_agents/api/chat_models/tests/test_chat_model_base.py
@@ -273,3 +273,56 @@ def test_setup_rejects_unrecognized_constructor_argument()
-> None:
"""Same guarantee as the connection, for BaseChatModelSetup."""
with pytest.raises(ValidationError, match="not_a_real_field"):
_RecordingChatModelSetup(connection="c", model="m",
not_a_real_field="oops")
+
+
+def test_effective_model_for_reads_the_model_param() -> None:
+ """The default effective model is the ``model`` parameter a request
carries."""
+ connection = _RecordingConnection()
+
+ assert connection.effective_model_for({"model": "gpt-4o"}) == "gpt-4o"
+
+
+def test_effective_model_for_returns_none_when_no_model_param() -> None:
+ """A connection carrying no default of its own has no model to resolve.
+
+ The capability predicate reports a ``None`` model not capable rather than
raising,
+ so answering ``None`` degrades to the prompt-engineering fallback.
+ """
+ connection = _RecordingConnection()
+
+ assert connection.effective_model_for({}) is None
+ assert connection.effective_model_for({"temperature": 0.5}) is None
+
+
+def test_effective_model_for_returns_none_for_none_params() -> None:
+ """No parameters at all resolve to no model, rather than raising."""
+ connection = _RecordingConnection()
+
+ assert connection.effective_model_for(None) is None
+
+
+def test_effective_model_for_does_not_normalize_a_blank_model() -> None:
+ """The hook answers with what it was given rather than validating it.
+
+ Substituting a default for a blank model is what the connections carrying a
+ default model do, and it belongs to their override because it is what their
+ request builder does. Doing it here would invent a fallback for
connections that
+ have none.
+ """
+ connection = _RecordingConnection()
+
+ assert connection.effective_model_for({"model": " "}) == " "
+
+
+def test_effective_model_for_does_not_consume_the_params() -> None:
+ """Reading the model leaves the mapping able to build the request it
described.
+
+ An override copying a request builder's ``pop`` idiom would hand that
builder a
+ map with the model already removed, so the contract is pinned on the
default too.
+ """
+ connection = _RecordingConnection()
+ model_kwargs = {"model": "gpt-4o", "temperature": 0.5}
+
+ connection.effective_model_for(model_kwargs)
+
+ assert model_kwargs == {"model": "gpt-4o", "temperature": 0.5}
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 33466cea..811f3648 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
@@ -753,3 +753,59 @@ def test_sampling_and_prefill_boundaries_differ() -> None:
assert _supports_json_prefill("claude-sonnet-4-6") is False
assert _supports_sampling_params("claude-sonnet-4-6") is True
assert _sent_sampling("claude-sonnet-4-6", temperature=0.1)["temperature"]
== 0.1
+
+
+def _judging_connection() -> tuple[AnthropicChatModelConnection, list]:
+ """A connection recording every model its request path judges for
capability.
+
+ Subclassing keeps the predicate itself under test rather than standing a
stub in
+ for it: the override notes what it was asked about and delegates to the
real one.
+ """
+ judged: list = []
+
+ class _JudgingConnection(AnthropicChatModelConnection):
+ def supports_native_structured_output(
+ self, effective_model: str | None
+ ) -> bool:
+ judged.append(effective_model)
+ return super().supports_native_structured_output(effective_model)
+
+ connection = _JudgingConnection(api_key="dummy")
+ client = MagicMock()
+ client.messages.create.return_value = Message(
+ id="m",
+ model="claude",
+ role="assistant",
+ type="message",
+ stop_reason="end_turn",
+ content=[TextBlock(type="text", text="ok")],
+ usage=_usage(),
+ )
+ connection._client = client
+ return connection, judged
+
+
[email protected](
+ "model_kwargs",
+ [{"model": _CAPABLE_MODEL}, {"model": _INCAPABLE_MODEL}, {"model": ""},
{}],
+ ids=["capable", "incapable", "blank", "absent"],
+)
+def test_effective_model_for_names_the_model_the_request_judges(
+ model_kwargs: Dict[str, Any],
+) -> None:
+ """The hook names exactly the model the request path asks the predicate
about.
+
+ This connection reads the parameter without a fallback, so the inherited
hook is
+ already the right answer. Pinning it against what the builder judges is
what would
+ catch a fallback being added here without a matching override.
+ """
+ connection, judged = _judging_connection()
+
+ named = connection.effective_model_for(model_kwargs)
+ connection.chat(
+ [ChatMessage(role=MessageRole.USER, content="hi")],
+ output_schema=OutputSchema(output_schema=_Answer),
+ **model_kwargs,
+ )
+
+ assert judged == [named]
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 65a614c0..2e72b8eb 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
@@ -17,7 +17,7 @@
#################################################################################
import logging
import re
-from typing import Any, Dict, List, Sequence
+from typing import Any, Dict, List, Mapping, Sequence
from openai import NOT_GIVEN, AzureOpenAI
@@ -231,6 +231,21 @@ class
AzureOpenAIChatModelConnection(BaseChatModelConnection):
return False
return effective_model in _NATIVE_STRUCTURED_OUTPUT_MODELS
+ @override
+ def effective_model_for(self, model_kwargs: Mapping[str, Any] | None) ->
str | None:
+ """The model backing the deployment, read from
``model_of_azure_deployment``.
+
+ The ``model`` parameter carries the deployment name the request is
issued
+ against. That name is chosen by the user, and although it commonly
echoes the
+ model behind it, nothing keeps the two in step once the deployment is
+ repointed, so it is never the answer here. Leaving the backing model
unset
+ keeps even a capable deployment on the prompt-engineering fallback
rather than
+ classifying a deployment name on its spelling.
+ """
+ if model_kwargs is None:
+ return None
+ return model_kwargs.get("model_of_azure_deployment")
+
def _api_version_supports_structured_output(self) -> bool:
"""Whether the configured api-version reaches the structured-output
floor.
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 ade5c536..e15b3f62 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
@@ -512,3 +512,108 @@ def test_map_member_schema_is_accepted_and_sent_whole()
-> None:
_chat_with_schema(conn, Labelled)
response_format = _create_call_kwargs(conn)["response_format"]
assert response_format["json_schema"]["schema"] ==
to_strict_json_schema(Labelled)
+
+
+def _model_kwargs(backing_model: str | None = None) -> dict[str, Any]:
+ """The parameter map a setup hands the connection for one request.
+
+ ``model`` is the deployment, matching every other call in this module; the
backing
+ model travels under its own key and is omitted entirely when unset.
+ """
+ params: dict[str, Any] = {"model": DEPLOYMENT}
+ if backing_model is not None:
+ params["model_of_azure_deployment"] = backing_model
+ return params
+
+
+def _judging_connection() -> tuple[AzureOpenAIChatModelConnection, list[str |
None]]:
+ """A connection recording every model its request path judges for
capability.
+
+ Subclassing keeps the predicate itself under test rather than standing a
stub in
+ for it: the override notes what it was asked about and delegates to the
real one.
+ """
+ judged: list[str | None] = []
+
+ class _JudgingConnection(AzureOpenAIChatModelConnection):
+ def supports_native_structured_output(
+ self, effective_model: str | None
+ ) -> bool:
+ judged.append(effective_model)
+ return super().supports_native_structured_output(effective_model)
+
+ conn = _JudgingConnection(
+ api_key="test-key",
+ azure_endpoint="https://example.openai.azure.com",
+ api_version=CAPABLE_API_VERSION,
+ )
+ 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, judged
+
+
+def test_effective_model_for_returns_backing_model() -> None:
+ """Capability is asked about the model behind the deployment, not the
deployment."""
+ assert (
+ _connection().effective_model_for(_model_kwargs("gpt-4o-mini")) ==
"gpt-4o-mini"
+ )
+
+
+def test_effective_model_for_returns_none_when_backing_model_unset() -> None:
+ """An unset backing model resolves to nothing rather than to the
deployment.
+
+ Falling back to the deployment would classify a user-chosen name on
nothing but
+ its spelling, and that name stops tracking the model behind it the moment
the
+ deployment is repointed.
+ """
+ assert _connection().effective_model_for(_model_kwargs()) is None
+
+
+def test_effective_model_for_does_not_consume_the_backing_model() -> None:
+ """The hook reads the key that ``chat`` pops, and has to leave it in place.
+
+ An override copying the builder's ``pop`` idiom would hand the builder a
map with
+ no backing model, and the native branch would silently disappear.
+ """
+ model_kwargs = _model_kwargs("gpt-4o-mini")
+
+ _connection().effective_model_for(model_kwargs)
+
+ assert model_kwargs["model_of_azure_deployment"] == "gpt-4o-mini"
+
+
[email protected](
+ "backing_model",
+ ["gpt-4o-mini", "some-unknown-model", None],
+ ids=["capable", "unknown", "unset"],
+)
+def test_effective_model_for_names_the_model_the_request_judges(
+ backing_model: str | None,
+) -> None:
+ """The hook names exactly the model the request path asks the predicate
about.
+
+ The hook duplicates the builder's resolution rather than centralizing it,
so only
+ capturing what the request feeds the predicate keeps the two from drifting
apart;
+ comparing each against a literal would let them drift in step. A fresh
connection
+ per case is what makes the single-element comparison also an assertion
that the
+ predicate was reached at all.
+ """
+ conn, judged = _judging_connection()
+ model_kwargs = _model_kwargs(backing_model)
+
+ named = conn.effective_model_for(model_kwargs)
+ conn.chat(
+ [ChatMessage(role=MessageRole.USER, content="hi")],
+ output_schema=OutputSchema(output_schema=Person),
+ **model_kwargs,
+ )
+
+ assert judged == [named]
+ assert judged[0] != DEPLOYMENT
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 d8b1d2c0..3b8d0f49 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
@@ -268,3 +268,59 @@ def test_map_member_schema_is_accepted_and_sent_whole() ->
None:
_chat_with_schema(conn, Labelled)
response_format = _create_call_kwargs(conn)["response_format"]
assert response_format["json_schema"]["schema"] ==
to_strict_json_schema(Labelled)
+
+
+def _judging_connection() -> tuple[OpenAIChatModelConnection, list[str |
None]]:
+ """A connection recording every model its request path judges for
capability.
+
+ Subclassing keeps the predicate itself under test rather than standing a
stub in
+ for it: the override notes what it was asked about and delegates to the
real one.
+ """
+ judged: list[str | None] = []
+
+ class _JudgingConnection(OpenAIChatModelConnection):
+ def supports_native_structured_output(
+ self, effective_model: str | None
+ ) -> bool:
+ judged.append(effective_model)
+ return super().supports_native_structured_output(effective_model)
+
+ conn = _JudgingConnection(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_message.refusal = 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, judged
+
+
[email protected](
+ "model_kwargs",
+ [{"model": "gpt-4o-mini"}, {"model": "an-unknown-model"}, {"model": ""},
{}],
+ ids=["capable", "unknown", "blank", "absent"],
+)
+def test_effective_model_for_names_the_model_the_request_judges(
+ model_kwargs: dict[str, Any],
+) -> None:
+ """The hook names exactly the model the request path asks the predicate
about.
+
+ This connection reads the parameter without a fallback, so the inherited
hook is
+ already the right answer. Pinning it against what the builder judges is
what would
+ catch a fallback being added here without a matching override.
+ """
+ conn, judged = _judging_connection()
+
+ named = conn.effective_model_for(model_kwargs)
+ conn.chat(
+ [ChatMessage(role=MessageRole.USER, content="hi")],
+ output_schema=OutputSchema(output_schema=Person),
+ **model_kwargs,
+ )
+
+ assert judged == [named]
diff --git
a/python/flink_agents/integrations/chat_models/tests/test_ollama_native_structured_output.py
b/python/flink_agents/integrations/chat_models/tests/test_ollama_native_structured_output.py
index 0d1d2934..1cf559d6 100644
---
a/python/flink_agents/integrations/chat_models/tests/test_ollama_native_structured_output.py
+++
b/python/flink_agents/integrations/chat_models/tests/test_ollama_native_structured_output.py
@@ -149,3 +149,67 @@ def test_schema_accepted_not_rejected() -> None:
_messages(), model="qwen3",
output_schema=OutputSchema(output_schema=row_type)
)
assert response.content == "ok"
+
+
+def _judging_connection() -> tuple[OllamaChatModelConnection, list]:
+ """A connection recording every model its request path judges for
capability.
+
+ Subclassing keeps the predicate itself under test rather than standing a
stub in
+ for it: the override notes what it was asked about and delegates to the
real one.
+ """
+ judged: list = []
+
+ class _JudgingConnection(OllamaChatModelConnection):
+ def supports_native_structured_output(
+ self, effective_model: str | None
+ ) -> bool:
+ judged.append(effective_model)
+ return super().supports_native_structured_output(effective_model)
+
+ conn = _JudgingConnection()
+ response = MagicMock()
+ response.message.role = "assistant"
+ response.message.content = "ok"
+ response.message.tool_calls = None
+ response.prompt_eval_count = 1
+ response.eval_count = 2
+ mock_client = MagicMock()
+ mock_client.chat.return_value = response
+ conn._OllamaChatModelConnection__client = mock_client
+ return conn, judged
+
+
[email protected](
+ "model_kwargs",
+ [{"model": "qwen3"}, {"model": ""}],
+ ids=["named", "blank"],
+)
+def test_effective_model_for_names_the_model_the_request_judges(
+ model_kwargs: Dict[str, Any],
+) -> None:
+ """The hook names exactly the model the request path asks the predicate
about.
+
+ Only parameter maps that name a model are exercised: this builder pops
``model``
+ with no fallback, so an absent one is a request that cannot be built
rather than a
+ disagreement about which model to judge. The hook still answers ``None``
there,
+ since it resolves rather than validates.
+ """
+ conn, judged = _judging_connection()
+
+ named = conn.effective_model_for(model_kwargs)
+ conn.chat(
+ _messages(),
+ output_schema=OutputSchema(output_schema=Person),
+ **model_kwargs,
+ )
+
+ assert judged == [named]
+
+
+def test_effective_model_for_resolves_nothing_without_a_model_param() -> None:
+ """A parameter map naming no model resolves to nothing rather than raising.
+
+ The request builder refuses that map, but the hook answers for whatever it
is
+ given, and the capability predicate accepts ``None`` without raising.
+ """
+ assert _connection().effective_model_for({}) is None
diff --git
a/python/flink_agents/integrations/chat_models/tests/test_tongyi_native_structured_output.py
b/python/flink_agents/integrations/chat_models/tests/test_tongyi_native_structured_output.py
index c249de51..2979330b 100644
---
a/python/flink_agents/integrations/chat_models/tests/test_tongyi_native_structured_output.py
+++
b/python/flink_agents/integrations/chat_models/tests/test_tongyi_native_structured_output.py
@@ -260,3 +260,92 @@ def
test_row_type_info_leaves_a_caller_response_format_alone(monkeypatch) -> Non
)
assert response.content == "ok"
assert kwargs["response_format"] == caller_format
+
+
+# The connection's default model, written out here rather than imported for
the same
+# reason as the lists above: a changed default is then a disagreement between
two
+# values rather than one value both sides read.
+_DEFAULT_MODEL = "qwen-plus"
+
+
+def _judging_connection() -> tuple[TongyiChatModelConnection, list[str |
None]]:
+ """A connection recording every model its request path judges for
capability.
+
+ Subclassing keeps the predicate itself under test rather than standing a
stub in
+ for it: the override notes what it was asked about and delegates to the
real one.
+ """
+ judged: list[str | None] = []
+
+ class _JudgingConnection(TongyiChatModelConnection):
+ def supports_native_structured_output(
+ self, effective_model: str | None
+ ) -> bool:
+ judged.append(effective_model)
+ return super().supports_native_structured_output(effective_model)
+
+ return _JudgingConnection(api_key="fake-key"), judged
+
+
+def test_effective_model_for_applies_the_default_model() -> None:
+ """A call naming no model resolves to the model the request would be
issued to.
+
+ Reading the parameter alone answers ``None`` for every such call and
reports the
+ default model incapable without ever asking about it, which is the failure
the
+ comment beside the request builder's own lookup warns about.
+ """
+ assert _connection().effective_model_for({}) == _DEFAULT_MODEL
+ assert _connection().effective_model_for(None) == _DEFAULT_MODEL
+
+
+def test_effective_model_for_reads_an_explicit_model() -> None:
+ """A named model is answered as given, not replaced by the default."""
+ assert (
+ _connection().effective_model_for({"model": _CAPABLE_MODEL}) ==
_CAPABLE_MODEL
+ )
+
+
+def test_effective_model_for_keeps_a_present_but_empty_model() -> None:
+ """The default stands in for an absent model only, matching the request
builder.
+
+ The builder's fallback is a ``pop`` default, which applies when the key is
missing
+ and not when it is present and empty. Substituting the default for an
empty value
+ would make the hook and the request disagree on exactly that input.
+ """
+ assert _connection().effective_model_for({"model": ""}) == ""
+
+
+def test_effective_model_for_does_not_consume_the_model() -> None:
+ """The hook reads the key that ``chat`` pops, and has to leave it in
place."""
+ model_kwargs = {"model": _CAPABLE_MODEL}
+
+ _connection().effective_model_for(model_kwargs)
+
+ assert model_kwargs == {"model": _CAPABLE_MODEL}
+
+
[email protected](
+ "model_kwargs",
+ [{"model": _CAPABLE_MODEL}, {"model": "qwen-turbo"}, {"model": ""}, {}],
+ ids=["capable", "incapable", "blank", "absent"],
+)
+def test_effective_model_for_names_the_model_the_request_judges(
+ monkeypatch, model_kwargs: dict[str, Any]
+) -> None:
+ """The hook names exactly the model the request path asks the predicate
about.
+
+ The hook duplicates the builder's resolution rather than centralizing it,
so only
+ capturing what the request feeds the predicate keeps the two from drifting
apart.
+ A fresh connection per case is what makes the single-element comparison
also an
+ assertion that the predicate was reached at all.
+ """
+ conn, judged = _judging_connection()
+ _patched_call(monkeypatch)
+
+ named = conn.effective_model_for(model_kwargs)
+ conn.chat(
+ _messages(),
+ output_schema=OutputSchema(output_schema=Person),
+ **model_kwargs,
+ )
+
+ assert judged == [named]
diff --git a/python/flink_agents/integrations/chat_models/tongyi_chat_model.py
b/python/flink_agents/integrations/chat_models/tongyi_chat_model.py
index 0c05af5f..27879873 100644
--- a/python/flink_agents/integrations/chat_models/tongyi_chat_model.py
+++ b/python/flink_agents/integrations/chat_models/tongyi_chat_model.py
@@ -19,7 +19,7 @@ import contextlib
import json
import os
import uuid
-from typing import Any, Dict, List, Sequence, cast
+from typing import Any, Dict, List, Mapping, Sequence, cast
from dashscope import Generation
from pydantic import BaseModel, Field
@@ -184,6 +184,22 @@ class TongyiChatModelConnection(BaseChatModelConnection):
"""
return effective_model in _NATIVE_STRUCTURED_OUTPUT_MODELS
+ @override
+ def effective_model_for(self, model_kwargs: Mapping[str, Any] | None) ->
str | None:
+ """The ``model`` parameter, falling back to ``DEFAULT_MODEL`` when
absent.
+
+ ``chat`` resolves the model it calls the same way, so reading the
parameter
+ alone would answer ``None`` for every call that names no model, and
report the
+ default model incapable without ever asking about it.
+
+ The fallback stands in for an absent parameter only, matching the
request: a
+ parameter that is present but empty is passed through, so the hook and
the
+ request agree on that input too.
+ """
+ if model_kwargs is None:
+ return DEFAULT_MODEL
+ return model_kwargs.get("model", DEFAULT_MODEL)
+
def chat(
self,
messages: Sequence[ChatMessage],
diff --git
a/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_native_structured_output.py
b/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_native_structured_output.py
index 92a8ca18..3e8430f1 100644
---
a/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_native_structured_output.py
+++
b/python/flink_agents/integrations/chat_models/watsonx/tests/test_watsonx_native_structured_output.py
@@ -356,3 +356,98 @@ def test_chat_with_output_schema() -> None:
parsed = json.loads(response.content)
assert set(parsed) == {"verdict", "score"}
assert Answer(**parsed).verdict is not None
+
+
+def _judging_connection(
+ monkeypatch: pytest.MonkeyPatch,
+) -> Tuple[WatsonxChatModelConnection, list]:
+ """A mocked connection recording every model its request path judges.
+
+ Subclassing keeps the predicate itself under test rather than standing a
stub in
+ for it: the override notes what it was asked about and delegates to the
real one.
+ """
+ judged: list = []
+
+ class _JudgingConnection(WatsonxChatModelConnection):
+ def supports_native_structured_output(
+ self, effective_model: str | None
+ ) -> bool:
+ judged.append(effective_model)
+ return super().supports_native_structured_output(effective_model)
+
+ provider_model = MagicMock()
+ provider_model.chat.return_value = CHAT_RESPONSE
+ monkeypatch.setattr(
+
"flink_agents.integrations.chat_models.watsonx.watsonx_chat_model.ModelInference",
+ MagicMock(return_value=provider_model),
+ )
+ connection = _JudgingConnection(
+ url="https://us-south.ml.cloud.ibm.com",
+ api_key="fake-key",
+ project_id="fake-project",
+ )
+ connection._client = MagicMock()
+ return connection, judged
+
+
+def test_effective_model_for_applies_the_default_model() -> None:
+ """A call naming no model resolves to the model the request would be
issued to.
+
+ Reading the parameter alone would answer ``None`` where the request in
fact goes
+ to the default model, so the hook and the request would disagree on every
call
+ that names no model.
+ """
+ assert _connection().effective_model_for({}) == DEFAULT_MODEL
+ assert _connection().effective_model_for(None) == DEFAULT_MODEL
+
+
+def test_effective_model_for_reads_an_explicit_model() -> None:
+ """A named model is answered as given, not replaced by the default."""
+ assert _connection().effective_model_for({"model": "no-such-model"}) == (
+ "no-such-model"
+ )
+
+
+def test_effective_model_for_keeps_a_present_but_empty_model() -> None:
+ """The default stands in for an absent model only, matching the request
builder.
+
+ The builder's fallback is a ``pop`` default, which applies when the key is
missing
+ and not when it is present and empty.
+ """
+ assert _connection().effective_model_for({"model": ""}) == ""
+
+
+def test_effective_model_for_does_not_consume_the_model() -> None:
+ """The hook reads the key that ``chat`` pops, and has to leave it in
place."""
+ model_kwargs = {"model": "no-such-model"}
+
+ _connection().effective_model_for(model_kwargs)
+
+ assert model_kwargs == {"model": "no-such-model"}
+
+
[email protected](
+ "model_kwargs",
+ [{"model": "no-such-model"}, {"model": ""}, {}],
+ ids=["named", "blank", "absent"],
+)
+def test_effective_model_for_names_the_model_the_request_judges(
+ monkeypatch: pytest.MonkeyPatch, model_kwargs: Dict[str, Any]
+) -> None:
+ """The hook names exactly the model the request path asks the predicate
about.
+
+ Capability here is unconditional, so the predicate's answer cannot reveal a
+ disagreement; only the argument it was handed can. A fresh connection per
case is
+ what makes the single-element comparison also an assertion that the
predicate was
+ reached at all.
+ """
+ connection, judged = _judging_connection(monkeypatch)
+
+ named = connection.effective_model_for(model_kwargs)
+ connection.chat(
+ [ChatMessage(role=MessageRole.USER, content="Hello!")],
+ output_schema=OutputSchema(output_schema=Answer),
+ **model_kwargs,
+ )
+
+ assert judged == [named]
diff --git
a/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py
b/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py
index 414ff6f9..51c7742d 100644
--- a/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py
+++ b/python/flink_agents/integrations/chat_models/watsonx/watsonx_chat_model.py
@@ -22,7 +22,7 @@ import logging
import os
import time
import uuid
-from typing import Any, Dict, List, Sequence
+from typing import Any, Dict, List, Mapping, Sequence
import httpx
from ibm_watsonx_ai import APIClient, Credentials
@@ -404,6 +404,22 @@ class WatsonxChatModelConnection(BaseChatModelConnection):
"""
return True
+ @override
+ def effective_model_for(self, model_kwargs: Mapping[str, Any] | None) ->
str | None:
+ """The ``model`` parameter, falling back to ``DEFAULT_MODEL`` when
absent.
+
+ ``chat`` resolves the model it calls the same way, so reading the
parameter
+ alone would answer ``None`` where the request in fact goes to the
default
+ model.
+
+ The fallback stands in for an absent parameter only, matching the
request: a
+ parameter that is present but empty is passed through, so the hook and
the
+ request agree on that input too.
+ """
+ if model_kwargs is None:
+ return DEFAULT_MODEL
+ return model_kwargs.get("model", DEFAULT_MODEL)
+
def chat(
self,
messages: Sequence[ChatMessage],