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 c4a29ac6f [integrations][plan] Handle finish_reason in OpenAI-family 
connections (#1040)
c4a29ac6f is described below

commit c4a29ac6f8fee703f0cb3a57a514dfed626cc155
Author: Weiqing Yang <[email protected]>
AuthorDate: Tue Sep 1 22:02:48 2026 -0700

    [integrations][plan] Handle finish_reason in OpenAI-family connections 
(#1040)
    
    Generated-by: Claude Code 2.1.234 (Claude Opus 5)
    
    ---------
    
    Co-authored-by: weiqingy <[email protected]>
---
 .../openai/AzureOpenAIChatModelConnection.java     |  20 +-
 .../openai/OpenAICompletionsConnection.java        |  21 ++-
 .../openai/AzureOpenAIChatModelConnectionTest.java | 112 ++++++++++-
 .../openai/FakeOpenAICompletionsEndpoint.java      | 106 +++++++++++
 .../openai/OpenAICompletionsConnectionTest.java    |  94 ++++++++++
 .../flink/agents/plan/actions/ChatModelAction.java |  33 +++-
 .../agents/plan/actions/ChatModelInvoker.java      |   3 +
 .../plan/actions/ChatModelActionRetryTest.java     | 208 ++++++++++++++++++++-
 .../agents/plan/actions/ChatModelActionTest.java   |  40 ++++
 .../chat_models/azure/azure_openai_chat_model.py   |   9 +-
 .../tests/test_azure_openai_response_parsing.py    | 140 ++++++++++++++
 .../chat_models/openai/openai_chat_model.py        |   9 +-
 .../chat_models/openai/openai_utils.py             |  11 +-
 .../openai/tests/test_openai_response_parsing.py   | 203 +++++++++++++++++++-
 .../flink_agents/plan/actions/chat_model_action.py |  39 ++++
 .../plan/tests/actions/test_chat_model_action.py   |  30 +++
 .../tests/actions/test_chat_model_action_retry.py  | 203 +++++++++++++++++++-
 17 files changed, 1262 insertions(+), 19 deletions(-)

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 db3270db0..4ce122ad0 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
@@ -253,6 +253,11 @@ public class AzureOpenAIChatModelConnection extends 
BaseChatModelConnection {
                 >= 0;
     }
 
+    /**
+     * 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
+     * set, and the entry is absent when the provider reports none.
+     */
     @Override
     public ChatMessage chat(
             List<ChatMessage> messages, List<Tool> tools, Map<String, Object> 
modelParams) {
@@ -271,6 +276,10 @@ public class AzureOpenAIChatModelConnection extends 
BaseChatModelConnection {
      * carries no model information. Leaving that parameter unset therefore 
keeps even a capable
      * deployment on the fallback.
      *
+     * <p>When the provider reports a finish reason it is carried verbatim in 
{@code extraArgs}
+     * under {@code finish_reason}, including values outside the documented 
set, and the entry is
+     * absent when the provider reports none.
+     *
      * @throws IllegalArgumentException if the schema is applied natively 
while {@code
      *     additional_kwargs} also carries a {@code response_format}, since 
the two would otherwise
      *     compete on the same request
@@ -305,9 +314,16 @@ public class AzureOpenAIChatModelConnection extends 
BaseChatModelConnection {
         String modelOfAzureDeployment =
                 modelParams != null ? (String) 
modelParams.get("model_of_azure_deployment") : null;
 
+        ChatCompletion.Choice choice = completion.choices().get(0);
         ChatMessage response =
-                OpenAIChatCompletionsUtils.convertFromOpenAIMessage(
-                        completion.choices().get(0).message());
+                
OpenAIChatCompletionsUtils.convertFromOpenAIMessage(choice.message());
+
+        // ChatCompletion.Choice#finishReason throws 
OpenAIInvalidDataException when the member is
+        // absent or null, so the value is read through the raw field.
+        choice._finishReason()
+                .asKnown()
+                .ifPresent(
+                        reason -> response.getExtraArgs().put("finish_reason", 
reason.asString()));
 
         if (modelOfAzureDeployment != null
                 && !modelOfAzureDeployment.isBlank()
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 3af78b72c..4864769e5 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,12 +171,22 @@ public class OpenAICompletionsConnection extends 
BaseChatModelConnection {
                 || NATIVE_STRUCTURED_OUTPUT_MODELS.contains(effectiveModel);
     }
 
+    /**
+     * 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
+     * set, and the entry is absent when the provider reports none.
+     */
     @Override
     public ChatMessage chat(
             List<ChatMessage> messages, List<Tool> tools, Map<String, Object> 
modelParams) {
         return doChat(messages, tools, modelParams, null);
     }
 
+    /**
+     * 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
+     * set, and the entry is absent when the provider reports none.
+     */
     @Override
     public ChatMessage chat(
             List<ChatMessage> messages,
@@ -194,9 +204,16 @@ public class OpenAICompletionsConnection extends 
BaseChatModelConnection {
         ChatCompletionCreateParams params =
                 buildRequest(messages, tools, modelParams, outputSchema);
         ChatCompletion completion = client.chat().completions().create(params);
+        ChatCompletion.Choice choice = completion.choices().get(0);
         ChatMessage response =
-                OpenAIChatCompletionsUtils.convertFromOpenAIMessage(
-                        completion.choices().get(0).message());
+                
OpenAIChatCompletionsUtils.convertFromOpenAIMessage(choice.message());
+
+        // ChatCompletion.Choice#finishReason throws 
OpenAIInvalidDataException when the member is
+        // absent or null, so the value is read through the raw field.
+        choice._finishReason()
+                .asKnown()
+                .ifPresent(
+                        reason -> response.getExtraArgs().put("finish_reason", 
reason.asString()));
 
         // Stash token usage
         if (completion.usage().isPresent()) {
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 b47cbbdce..24d448a74 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
@@ -21,6 +21,9 @@ package 
org.apache.flink.agents.integrations.chatmodels.openai;
 import com.fasterxml.jackson.annotation.JsonSubTypes;
 import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import com.fasterxml.jackson.core.type.TypeReference;
+import com.openai.core.JsonField;
+import com.openai.core.JsonMissing;
+import com.openai.core.JsonValue;
 import com.openai.errors.BadRequestException;
 import com.openai.models.ChatModel;
 import com.openai.models.ResponseFormatJsonSchema;
@@ -52,9 +55,11 @@ import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Stream;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /**
@@ -617,6 +622,106 @@ class AzureOpenAIChatModelConnectionTest {
                 .doesNotContainKeys("model_name", "promptTokens", 
"completionTokens");
     }
 
+    @Test
+    @DisplayName("The finish reason reported by the provider reaches the 
response extra args")
+    void testResponseCarriesFinishReason() {
+        ChatMessage response =
+                connection()
+                        .toResponse(
+                                completionWithFinishReasonAndUsage(
+                                        
JsonField.of(ChatCompletion.Choice.FinishReason.LENGTH)),
+                                params("gpt-4o-mini"));
+
+        assertThat(response.getExtraArgs()).containsEntry("finish_reason", 
"length");
+    }
+
+    @Test
+    @DisplayName("A finish reason outside the documented set is stored as 
received")
+    void testResponseCarriesUnknownFinishReasonVerbatim() {
+        ChatMessage response =
+                connection()
+                        .toResponse(
+                                completionWithFinishReasonAndUsage(
+                                        JsonField.of(
+                                                
ChatCompletion.Choice.FinishReason.of(
+                                                        
"some_vendor_reason"))),
+                                params("gpt-4o-mini"));
+
+        assertThat(response.getExtraArgs()).containsEntry("finish_reason", 
"some_vendor_reason");
+    }
+
+    @Test
+    @DisplayName("An empty finish reason is recorded rather than discarded")
+    void testResponseCarriesEmptyFinishReason() {
+        // The choice carries a value, so it is recorded; emptiness is not 
treated as absence.
+        ChatMessage response =
+                connection()
+                        .toResponse(
+                                completionWithFinishReasonAndUsage(
+                                        
JsonField.of(ChatCompletion.Choice.FinishReason.of(""))),
+                                params("gpt-4o-mini"));
+
+        assertThat(response.getExtraArgs()).containsEntry("finish_reason", "");
+    }
+
+    @Test
+    @DisplayName("The finish reason is captured independently of the token 
metrics")
+    void testResponseCarriesFinishReasonWithoutTokenMetrics() {
+        // The metrics need both a backing model and a usage report, and 
neither is supplied here,
+        // so the absent promptTokens proves that branch did not run and could 
not have written it.
+        ChatMessage response =
+                connection()
+                        .toResponse(
+                                completionWithFinishReasonWithoutUsage(
+                                        JsonField.of(
+                                                
ChatCompletion.Choice.FinishReason.TOOL_CALLS)),
+                                params(null));
+
+        assertThat(response.getExtraArgs())
+                .containsEntry("finish_reason", "tool_calls")
+                .doesNotContainKey("promptTokens");
+    }
+
+    private static Stream<Arguments> finishReasonsWithoutAValue() {
+        return Stream.of(
+                Arguments.of("field_absent", JsonMissing.of()),
+                Arguments.of("json_null", JsonValue.from(null)));
+    }
+
+    @ParameterizedTest(name = "{0}")
+    @MethodSource("finishReasonsWithoutAValue")
+    @DisplayName("A choice carrying no finish reason value yields no key and 
no error")
+    void testNoFinishReasonKeyWhenTheChoiceCarriesNoValue(
+            String label, JsonField<ChatCompletion.Choice.FinishReason> 
finishReason) {
+        // ChatCompletion.Choice#finishReason throws 
OpenAIInvalidDataException for both of these
+        // inputs, so reading the value has to go through the raw field.
+        AzureOpenAIChatModelConnection connection = connection();
+        ChatCompletion completion = 
completionWithFinishReasonAndUsage(finishReason);
+        AtomicReference<ChatMessage> response = new AtomicReference<>();
+
+        assertThatCode(() -> response.set(connection.toResponse(completion, 
params("gpt-4o-mini"))))
+                .doesNotThrowAnyException();
+
+        
assertThat(response.get().getExtraArgs()).doesNotContainKey("finish_reason");
+    }
+
+    private static ChatCompletion completionWithFinishReasonAndUsage(
+            JsonField<ChatCompletion.Choice.FinishReason> finishReason) {
+        return completionBuilder(finishReason)
+                .usage(
+                        CompletionUsage.builder()
+                                .promptTokens(11L)
+                                .completionTokens(7L)
+                                .totalTokens(18L)
+                                .build())
+                .build();
+    }
+
+    private static ChatCompletion completionWithFinishReasonWithoutUsage(
+            JsonField<ChatCompletion.Choice.FinishReason> finishReason) {
+        return completionBuilder(finishReason).build();
+    }
+
     private static ChatCompletion completionWithUsage(long promptTokens, long 
completionTokens) {
         return completionBuilder()
                 .usage(
@@ -633,6 +738,11 @@ class AzureOpenAIChatModelConnectionTest {
     }
 
     private static ChatCompletion.Builder completionBuilder() {
+        return 
completionBuilder(JsonField.of(ChatCompletion.Choice.FinishReason.STOP));
+    }
+
+    private static ChatCompletion.Builder completionBuilder(
+            JsonField<ChatCompletion.Choice.FinishReason> finishReason) {
         ChatCompletionMessage message =
                 
ChatCompletionMessage.builder().content("hi").refusal(Optional.empty()).build();
         return ChatCompletion.builder()
@@ -641,7 +751,7 @@ class AzureOpenAIChatModelConnectionTest {
                 .model(DEPLOYMENT)
                 .addChoice(
                         ChatCompletion.Choice.builder()
-                                
.finishReason(ChatCompletion.Choice.FinishReason.STOP)
+                                .finishReason(finishReason)
                                 .index(0L)
                                 .logprobs(Optional.empty())
                                 .message(message)
diff --git 
a/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/FakeOpenAICompletionsEndpoint.java
 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/FakeOpenAICompletionsEndpoint.java
new file mode 100644
index 000000000..6e1e633b9
--- /dev/null
+++ 
b/integrations/chat-models/openai/src/test/java/org/apache/flink/agents/integrations/chatmodels/openai/FakeOpenAICompletionsEndpoint.java
@@ -0,0 +1,106 @@
+/*
+ * 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.sun.net.httpserver.HttpServer;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * A loopback HTTP endpoint that answers every request with a successful chat 
completion, so a
+ * connection's response-handling path can be exercised without a live API 
call. The choice's {@code
+ * finish_reason} and the top-level {@code usage} block are chosen per 
instance, including the
+ * shapes that carry no finish reason at all: the member set to JSON null, and 
the member left out
+ * of the choice entirely.
+ */
+final class FakeOpenAICompletionsEndpoint implements AutoCloseable {
+
+    private static final String CONTENT = "hi";
+
+    private static final String NO_FINISH_REASON_MEMBER = "";
+
+    private static final String USAGE_MEMBER =
+            
",\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":7,\"total_tokens\":18}";
+
+    private final HttpServer server;
+
+    private FakeOpenAICompletionsEndpoint(HttpServer server) {
+        this.server = server;
+    }
+
+    static FakeOpenAICompletionsEndpoint servingFinishReason(String 
finishReason)
+            throws IOException {
+        return serving(finishReasonMember("\"" + finishReason + "\""), 
USAGE_MEMBER);
+    }
+
+    static FakeOpenAICompletionsEndpoint 
servingFinishReasonWithoutUsage(String finishReason)
+            throws IOException {
+        return serving(finishReasonMember("\"" + finishReason + "\""), "");
+    }
+
+    static FakeOpenAICompletionsEndpoint servingNullFinishReason() throws 
IOException {
+        return serving(finishReasonMember("null"), USAGE_MEMBER);
+    }
+
+    static FakeOpenAICompletionsEndpoint servingNoFinishReasonMember() throws 
IOException {
+        return serving(NO_FINISH_REASON_MEMBER, USAGE_MEMBER);
+    }
+
+    private static String finishReasonMember(String value) {
+        return "\"finish_reason\":" + value + ",";
+    }
+
+    private static FakeOpenAICompletionsEndpoint serving(
+            String finishReasonMember, String usageMember) throws IOException {
+        String completion =
+                
"{\"id\":\"completion-1\",\"object\":\"chat.completion\",\"created\":0,"
+                        + "\"model\":\"gpt-4o\",\"choices\":[{"
+                        + finishReasonMember
+                        + 
"\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\""
+                        + CONTENT
+                        + "\"}}]"
+                        + usageMember
+                        + "}";
+
+        HttpServer server = HttpServer.create(new 
InetSocketAddress("127.0.0.1", 0), 0);
+        byte[] body = completion.getBytes(StandardCharsets.UTF_8);
+        server.createContext(
+                "/",
+                exchange -> {
+                    exchange.getResponseHeaders().add("Content-Type", 
"application/json");
+                    exchange.sendResponseHeaders(200, body.length);
+                    exchange.getResponseBody().write(body);
+                    exchange.close();
+                });
+        server.setExecutor(null);
+        server.start();
+        return new FakeOpenAICompletionsEndpoint(server);
+    }
+
+    String baseUrl() {
+        return "http://127.0.0.1:"; + server.getAddress().getPort();
+    }
+
+    @Override
+    public void close() {
+        server.stop(0);
+    }
+}
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 5204de2cf..851e90043 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
@@ -41,8 +41,10 @@ import java.time.Duration;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 /**
@@ -183,6 +185,98 @@ class OpenAICompletionsConnectionTest {
         }
     }
 
+    @Test
+    @DisplayName("The finish reason reported by the provider reaches the 
response extra args")
+    void testResponseCarriesFinishReason() throws IOException {
+        try (FakeOpenAICompletionsEndpoint endpoint =
+                FakeOpenAICompletionsEndpoint.servingFinishReason("length")) {
+            ChatMessage response =
+                    connection(endpoint.baseUrl())
+                            .chat(userMessage(), List.of(), params("gpt-4o"), 
null);
+
+            assertThat(response.getExtraArgs()).containsEntry("finish_reason", 
"length");
+        }
+    }
+
+    @Test
+    @DisplayName("A finish reason outside the documented set is stored as 
received")
+    void testResponseCarriesUnknownFinishReasonVerbatim() throws IOException {
+        try (FakeOpenAICompletionsEndpoint endpoint =
+                
FakeOpenAICompletionsEndpoint.servingFinishReason("some_vendor_reason")) {
+            ChatMessage response =
+                    connection(endpoint.baseUrl())
+                            .chat(userMessage(), List.of(), params("gpt-4o"), 
null);
+
+            assertThat(response.getExtraArgs())
+                    .containsEntry("finish_reason", "some_vendor_reason");
+        }
+    }
+
+    @Test
+    @DisplayName("An empty finish reason is recorded rather than discarded")
+    void testResponseCarriesEmptyFinishReason() throws IOException {
+        // The choice carries a value, so it is recorded; emptiness is not 
treated as absence.
+        try (FakeOpenAICompletionsEndpoint endpoint =
+                FakeOpenAICompletionsEndpoint.servingFinishReason("")) {
+            ChatMessage response =
+                    connection(endpoint.baseUrl())
+                            .chat(userMessage(), List.of(), params("gpt-4o"), 
null);
+
+            assertThat(response.getExtraArgs()).containsEntry("finish_reason", 
"");
+        }
+    }
+
+    @Test
+    @DisplayName("The finish reason is captured independently of the token 
metrics")
+    void testResponseCarriesFinishReasonWithoutUsage() throws IOException {
+        // The metrics come from the usage report the response here omits, so 
the absent
+        // promptTokens proves that branch did not run and could not have 
written the reason.
+        try (FakeOpenAICompletionsEndpoint endpoint =
+                
FakeOpenAICompletionsEndpoint.servingFinishReasonWithoutUsage("tool_calls")) {
+            ChatMessage response =
+                    connection(endpoint.baseUrl())
+                            .chat(userMessage(), List.of(), params("gpt-4o"), 
null);
+
+            assertThat(response.getExtraArgs())
+                    .containsEntry("finish_reason", "tool_calls")
+                    .doesNotContainKey("promptTokens");
+        }
+    }
+
+    @Test
+    @DisplayName("A choice with no finish_reason member yields no key and no 
error")
+    void testNoFinishReasonKeyWhenMemberAbsent() throws IOException {
+        try (FakeOpenAICompletionsEndpoint endpoint =
+                FakeOpenAICompletionsEndpoint.servingNoFinishReasonMember()) {
+            assertNoFinishReasonKey(endpoint);
+        }
+    }
+
+    @Test
+    @DisplayName("A choice whose finish_reason is JSON null yields no key and 
no error")
+    void testNoFinishReasonKeyWhenJsonNull() throws IOException {
+        try (FakeOpenAICompletionsEndpoint endpoint =
+                FakeOpenAICompletionsEndpoint.servingNullFinishReason()) {
+            assertNoFinishReasonKey(endpoint);
+        }
+    }
+
+    private static void assertNoFinishReasonKey(FakeOpenAICompletionsEndpoint 
endpoint) {
+        // ChatCompletion.Choice#finishReason throws 
OpenAIInvalidDataException for both of these
+        // response shapes, so reading the value has to go through the raw 
field.
+        OpenAICompletionsConnection connection = 
connection(endpoint.baseUrl());
+        AtomicReference<ChatMessage> response = new AtomicReference<>();
+
+        assertThatCode(
+                        () ->
+                                response.set(
+                                        connection.chat(
+                                                userMessage(), List.of(), 
params("gpt-4o"), null)))
+                .doesNotThrowAnyException();
+
+        
assertThat(response.get().getExtraArgs()).doesNotContainKey("finish_reason");
+    }
+
     @Test
     @DisplayName("Native response_format json_schema strict applied for a POJO 
on a capable model")
     void testNativeAppliedForPojoCapableModel() {
diff --git 
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java 
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java
index 57c191c75..2e8c2476d 100644
--- 
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java
+++ 
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelAction.java
@@ -97,6 +97,9 @@ public class ChatModelAction {
     private static final String RETRY_STATS_CONTEXT = "_RETRY_STATS_CONTEXT";
     private static final String TOTAL_RETRY_COUNT = "totalRetryCount";
     private static final String TOTAL_RETRY_WAIT_SEC = "totalRetryWaitSec";
+    private static final String FINISH_REASON = "finish_reason";
+    private static final String TRUNCATED_FINISH_REASON = "length";
+    private static final String CONTENT_FILTERED_FINISH_REASON = 
"content_filter";
 
     private static final ObjectMapper mapper = new ObjectMapper();
 
@@ -337,7 +340,7 @@ public class ChatModelAction {
             throw new RuntimeException(
                     String.format("Unsupported output schema %s.", 
outputSchema));
         }
-        Map<String, Object> extraArgs = new HashMap<>();
+        Map<String, Object> extraArgs = new HashMap<>(response.getExtraArgs());
         extraArgs.put(STRUCTURED_OUTPUT, structuredOutput);
         return new ChatMessage(response.getRole(), output, extraArgs);
     }
@@ -576,6 +579,34 @@ public class ChatModelAction {
         return routing;
     }
 
+    /**
+     * Rejects a response the provider did not finish emitting. Evaluated once 
per chat response,
+     * before it is dispatched as text, structured output, or tool calls. A 
finish reason reporting
+     * the content as cut off by the token budget or withheld by content 
filtering raises {@link
+     * IllegalStateException}; any other reason, and an absent one, are 
accepted.
+     */
+    static void rejectIncompleteResponse(ChatMessage response) {
+        Object finishReason = response.getExtraArgs().get(FINISH_REASON);
+        if (TRUNCATED_FINISH_REASON.equals(finishReason)) {
+            throw new IllegalStateException(
+                    String.format(
+                            "ChatModel response is truncated 
(finish_reason='%s'): it"
+                                    + " exhausted the completion token budget 
before the model"
+                                    + " finished, so the content is 
incomplete. Raise the"
+                                    + " model's max output tokens, or ask for 
a smaller output.",
+                            finishReason));
+        }
+        if (CONTENT_FILTERED_FINISH_REASON.equals(finishReason)) {
+            throw new IllegalStateException(
+                    String.format(
+                            "ChatModel response was withheld by the provider's 
content"
+                                    + " filter (finish_reason='%s'), so the 
content is"
+                                    + " incomplete. Adjust the prompt or the 
provider's content"
+                                    + " filtering configuration.",
+                            finishReason));
+        }
+    }
+
     static ChatMessage generateStructuredOutputWithReport(
             RunnerContext ctx, ChatMessage response, Object outputSchema) 
throws Exception {
         ExecutionReporters.started(ctx, ExecutionReporter.EntityTypes.PARSER, 
STRUCTURED_OUTPUT);
diff --git 
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelInvoker.java 
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelInvoker.java
index 8dfacee4e..4f49feac7 100644
--- 
a/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelInvoker.java
+++ 
b/plan/src/main/java/org/apache/flink/agents/plan/actions/ChatModelInvoker.java
@@ -174,6 +174,9 @@ final class ChatModelInvoker {
                 ExecutionReporters.succeeded(
                         ctx, ExecutionReporter.EntityTypes.LLM, model, 
llmMetadata);
                 ChatModelAction.recordChatTokenMetrics(chatModel, response, 
requestMetricGroup);
+                // A truncated response consumed its full token budget, so the 
token metrics
+                // above are recorded before this rejects and abandons the 
response.
+                ChatModelAction.rejectIncompleteResponse(response);
                 // only generate structured output for final response.
                 if (outputSchema != null && response.getToolCalls().isEmpty()) 
{
                     response =
diff --git 
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionRetryTest.java
 
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionRetryTest.java
index a1d7b0cf3..450ef22dc 100644
--- 
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionRetryTest.java
+++ 
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionRetryTest.java
@@ -37,6 +37,9 @@ import org.apache.flink.metrics.Counter;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.MockitoAnnotations;
@@ -47,6 +50,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.UUID;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
 
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -54,7 +58,10 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.*;
 
-/** Tests for retry behavior in {@link ChatModelAction}. */
+/**
+ * Tests for {@link ChatModelAction#chat} driven end to end: retry behavior, 
execution reporting,
+ * the finish-reason gate, and tool-response handling.
+ */
 class ChatModelActionRetryTest {
 
     private static final Map<String, Object> LLM_METADATA =
@@ -425,6 +432,205 @@ class ChatModelActionRetryTest {
         assertThat(promptArgsCaptor.getValue()).isEqualTo(savedPromptArgs);
     }
 
+    @Test
+    void chatRejectsTruncatedTextResponse() throws Exception {
+        RunnerContext reportingCtx = reportingRunnerContext();
+        BaseChatModelSetup chatModel = 
configureReportingChatContext(reportingCtx);
+        when(chatModel.chat(any(), any(), any()))
+                .thenReturn(
+                        new ChatMessage(
+                                MessageRole.ASSISTANT,
+                                "partial answ",
+                                Map.of("finish_reason", "length")));
+
+        assertThatThrownBy(
+                        () ->
+                                ChatModelAction.chat(
+                                        UUID.randomUUID(),
+                                        "test-model",
+                                        List.of(new 
ChatMessage(MessageRole.USER, "hi")),
+                                        Map.of(),
+                                        null,
+                                        reportingCtx))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("truncated")
+                .hasMessageContaining("token");
+
+        assertThat(sentEvents).isEmpty();
+    }
+
+    @Test
+    void chatRejectsContentFilteredTextResponse() throws Exception {
+        RunnerContext reportingCtx = reportingRunnerContext();
+        BaseChatModelSetup chatModel = 
configureReportingChatContext(reportingCtx);
+        when(chatModel.chat(any(), any(), any()))
+                .thenReturn(
+                        new ChatMessage(
+                                MessageRole.ASSISTANT,
+                                "",
+                                Map.of("finish_reason", "content_filter")));
+
+        // Both rejection messages interpolate the finish reason, so the 
literal
+        // content_filter appears in either one and cannot tell them apart. 
These
+        // match prose unique to the filtering message.
+        assertThatThrownBy(
+                        () ->
+                                ChatModelAction.chat(
+                                        UUID.randomUUID(),
+                                        "test-model",
+                                        List.of(new 
ChatMessage(MessageRole.USER, "hi")),
+                                        Map.of(),
+                                        null,
+                                        reportingCtx))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("withheld")
+                .hasMessageContaining("content filter");
+
+        assertThat(sentEvents).isEmpty();
+    }
+
+    @Test
+    void chatRejectsTruncatedToolCallResponseBeforeDispatchingTools() throws 
Exception {
+        RunnerContext reportingCtx = reportingRunnerContext();
+        BaseChatModelSetup chatModel = 
configureReportingChatContext(reportingCtx);
+        when(chatModel.chat(any(), any(), any()))
+                .thenReturn(
+                        new ChatMessage(
+                                MessageRole.ASSISTANT,
+                                "",
+                                List.of(
+                                        Map.of(
+                                                "id",
+                                                "call-1",
+                                                "function",
+                                                Map.of("name", "f", 
"arguments", ""))),
+                                Map.of("finish_reason", "length")));
+
+        assertThatThrownBy(
+                        () ->
+                                ChatModelAction.chat(
+                                        UUID.randomUUID(),
+                                        "test-model",
+                                        List.of(new 
ChatMessage(MessageRole.USER, "hi")),
+                                        Map.of(),
+                                        null,
+                                        reportingCtx))
+                .isInstanceOf(IllegalStateException.class)
+                .hasMessageContaining("truncated");
+
+        // A truncated tool call carries arguments the model never finished 
writing,
+        // so no ToolRequestEvent may leave the action.
+        assertThat(sentEvents).isEmpty();
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"length", "content_filter"})
+    void chatRejectedFinishReasonSkipsStructuredOutput(String finishReason) 
throws Exception {
+        RunnerContext reportingCtx = reportingRunnerContext();
+        BaseChatModelSetup chatModel = 
configureReportingChatContext(reportingCtx);
+        when(chatModel.chat(any(), any(), any()))
+                .thenReturn(
+                        new ChatMessage(
+                                MessageRole.ASSISTANT,
+                                "{\"answer\":\"42\"}",
+                                Map.of(
+                                        "finish_reason",
+                                        finishReason,
+                                        "model_name",
+                                        "provider-model",
+                                        "promptTokens",
+                                        100L,
+                                        "completionTokens",
+                                        50L)));
+
+        assertThatThrownBy(
+                        () ->
+                                ChatModelAction.chat(
+                                        UUID.randomUUID(),
+                                        "test-model",
+                                        List.of(new 
ChatMessage(MessageRole.USER, "hi")),
+                                        Map.of(),
+                                        Map.class,
+                                        reportingCtx))
+                .isInstanceOf(IllegalStateException.class);
+
+        ExecutionReporter reporter = (ExecutionReporter) reportingCtx;
+        // The model call itself succeeded and spent its full token budget, so 
both
+        // must be recorded before the response is rejected.
+        verify(reporter)
+                .reportExecutionSucceeded(
+                        ExecutionReporter.EntityTypes.LLM, "test-model", 
LLM_METADATA);
+        verify(chatModel).recordTokenMetrics(mockActionMetricGroup, 
"provider-model", 100L, 50L);
+        // The parse is never attempted, so nothing about it is reported. The 
failed
+        // check is unscoped: the rejection must not be reported as a failure 
of any
+        // entity, the model call included.
+        verify(reporter, never())
+                .reportExecutionStarted(
+                        eq(ExecutionReporter.EntityTypes.PARSER), anyString(), 
any());
+        verify(reporter, never())
+                .reportExecutionSucceeded(
+                        eq(ExecutionReporter.EntityTypes.PARSER), anyString(), 
any());
+        verify(reporter, never())
+                .reportExecutionFailed(anyString(), anyString(), any(), any(), 
any());
+        assertThat(sentEvents).isEmpty();
+    }
+
+    @Test
+    void chatIgnoreStrategyDropsRejectedResponseWithoutEvent() throws 
Exception {
+        RunnerContext reportingCtx = reportingRunnerContext();
+        BaseChatModelSetup chatModel = 
configureReportingChatContext(reportingCtx);
+        when(reportingCtx.getConfig())
+                
.thenReturn(readableConfig(Agent.ErrorHandlingStrategy.IGNORE));
+        when(chatModel.chat(any(), any(), any()))
+                .thenReturn(
+                        new ChatMessage(
+                                MessageRole.ASSISTANT,
+                                "partial answ",
+                                Map.of("finish_reason", "length")));
+
+        // Under IGNORE the record is dropped: the rejection does not 
propagate and no
+        // event carries the truncated content downstream.
+        ChatModelAction.chat(
+                UUID.randomUUID(),
+                "test-model",
+                List.of(new ChatMessage(MessageRole.USER, "hi")),
+                Map.of(),
+                null,
+                reportingCtx);
+
+        assertThat(sentEvents).isEmpty();
+    }
+
+    private static Stream<Map<String, Object>> acceptedFinishReasons() {
+        return Stream.of(
+                Map.of("finish_reason", "stop"),
+                Map.of("finish_reason", "tool_calls"),
+                Map.of("finish_reason", "some_vendor_reason"),
+                Map.of());
+    }
+
+    @ParameterizedTest
+    @MethodSource("acceptedFinishReasons")
+    void chatAcceptedFinishReasonReachesTheResponseEvent(Map<String, Object> 
extraArgs)
+            throws Exception {
+        RunnerContext reportingCtx = reportingRunnerContext();
+        BaseChatModelSetup chatModel = 
configureReportingChatContext(reportingCtx);
+        when(chatModel.chat(any(), any(), any()))
+                .thenReturn(new ChatMessage(MessageRole.ASSISTANT, "hello", 
extraArgs));
+
+        ChatModelAction.chat(
+                UUID.randomUUID(),
+                "test-model",
+                List.of(new ChatMessage(MessageRole.USER, "hi")),
+                Map.of(),
+                null,
+                reportingCtx);
+
+        assertThat(sentEvents).hasSize(1);
+        
assertThat(ChatResponseEvent.fromEvent(sentEvents.get(0)).getResponse().getContent())
+                .isEqualTo("hello");
+    }
+
     // --- Helper methods ---
 
     private void configureRetryStrategy(int maxRetries, int waitIntervalSec) {
diff --git 
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionTest.java
 
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionTest.java
index 46485b5a1..cfdffd03e 100644
--- 
a/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionTest.java
+++ 
b/plan/src/test/java/org/apache/flink/agents/plan/actions/ChatModelActionTest.java
@@ -17,16 +17,20 @@
  */
 package org.apache.flink.agents.plan.actions;
 
+import org.apache.flink.agents.api.agents.Agent;
 import org.apache.flink.agents.api.chat.messages.ChatMessage;
 import org.apache.flink.agents.api.chat.messages.MessageRole;
 import org.apache.flink.agents.api.chat.model.BaseChatModelSetup;
+import org.apache.flink.agents.api.context.RunnerContext;
 import org.apache.flink.agents.api.metrics.FlinkAgentsMetricGroup;
+import org.apache.flink.agents.api.trace.ExecutionReporter;
 import org.junit.jupiter.api.Test;
 
 import java.util.HashMap;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -34,14 +38,29 @@ import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.withSettings;
 
 /** Tests for {@link ChatModelAction}. */
 class ChatModelActionTest {
 
+    private static final String PARSEABLE_CONTENT = "{\"answer\":\"42\"}";
+
     private static ChatMessage responseWith(Map<String, Object> extraArgs) {
         return new ChatMessage(MessageRole.ASSISTANT, "response", extraArgs);
     }
 
+    private static RunnerContext reportingContext() {
+        return mock(RunnerContext.class, 
withSettings().extraInterfaces(ExecutionReporter.class));
+    }
+
+    private static ChatMessage generateStructuredOutput(
+            RunnerContext ctx, Map<String, Object> extraArgs) throws Exception 
{
+        return ChatModelAction.generateStructuredOutputWithReport(
+                ctx,
+                new ChatMessage(MessageRole.ASSISTANT, PARSEABLE_CONTENT, 
extraArgs),
+                Map.class);
+    }
+
     @Test
     void testRecordChatTokenMetricsRecordsWhenAllKeysPresent() {
         BaseChatModelSetup setup = mock(BaseChatModelSetup.class);
@@ -179,4 +198,25 @@ class ChatModelActionTest {
         String expected = "{\n  \"key\": \"value\"\n}";
         assertEquals(expected, ChatModelAction.cleanLlmResponse(input));
     }
+
+    @Test
+    void testStructuredOutputParsesToExpectedValue() throws Exception {
+        ChatMessage parsed = generateStructuredOutput(reportingContext(), 
Map.of());
+
+        assertEquals(Map.of("answer", "42"), 
parsed.getExtraArgs().get(Agent.STRUCTURED_OUTPUT));
+    }
+
+    @Test
+    void testStructuredOutputPreservesInboundExtraArgs() throws Exception {
+        Map<String, Object> extraArgs = new HashMap<>();
+        extraArgs.put("finish_reason", "stop");
+        extraArgs.put("promptTokens", 100L);
+
+        Map<String, Object> parsedArgs =
+                generateStructuredOutput(reportingContext(), 
extraArgs).getExtraArgs();
+
+        assertEquals("stop", parsedArgs.get("finish_reason"));
+        assertEquals(100L, parsedArgs.get("promptTokens"));
+        assertTrue(parsedArgs.containsKey(Agent.STRUCTURED_OUTPUT), 
parsedArgs.toString());
+    }
 }
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 8d264e8bc..65a614c03 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
@@ -285,7 +285,8 @@ class 
AzureOpenAIChatModelConnection(BaseChatModelConnection):
         Returns:
         -------
         ChatMessage
-            Model response message
+            Model response message. When the response carries a finish reason,
+            it is available as ``extra_args["finish_reason"]``.
         """
         tool_specs = None
         if tools is not None:
@@ -362,7 +363,11 @@ class 
AzureOpenAIChatModelConnection(BaseChatModelConnection):
             extra_args["promptTokens"] = response.usage.prompt_tokens
             extra_args["completionTokens"] = response.usage.completion_tokens
 
-        message = response.choices[0].message
+        choice = response.choices[0]
+        if choice.finish_reason is not None:
+            extra_args["finish_reason"] = choice.finish_reason
+
+        message = choice.message
 
         return convert_from_openai_message(message, extra_args)
 
diff --git 
a/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_response_parsing.py
 
b/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_response_parsing.py
new file mode 100644
index 000000000..e31f72bb1
--- /dev/null
+++ 
b/python/flink_agents/integrations/chat_models/azure/tests/test_azure_openai_response_parsing.py
@@ -0,0 +1,140 @@
+################################################################################
+#  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 unittest.mock import MagicMock
+
+from openai.types.chat import ChatCompletion
+
+from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.integrations.chat_models.azure.azure_openai_chat_model 
import (
+    AzureOpenAIChatModelConnection,
+)
+
+DEPLOYMENT = "my-deployment"
+
+ASSISTANT_MESSAGE = {
+    "role": "assistant",
+    "content": "ok",
+    "tool_calls": None,
+    "refusal": None,
+}
+USAGE = {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
+
+OMITTED = object()
+"""Sentinel for a response whose choice carries no finish_reason field at all,
+which is distinct from one carrying an explicit null."""
+
+
+def _connection(
+    finish_reason: object, usage: dict | None = None
+) -> AzureOpenAIChatModelConnection:
+    """Build a connection whose stubbed transport returns one real completion.
+
+    The transport is a mock but the payload is a genuine ``ChatCompletion``, so
+    the assertions exercise the SDK's own attribute access rather than values a
+    mock was told to return.
+
+    Parameters
+    ----------
+    finish_reason : object
+        Value for the choice's ``finish_reason``. Pass ``OMITTED`` to leave the
+        field out of the payload entirely. Required, so a caller cannot omit it
+        by accident and assert against an unintended shape.
+    usage : dict | None
+        Token usage block, or None to build a response carrying none.
+    """
+    choice: dict = {"index": 0, "message": ASSISTANT_MESSAGE}
+    if finish_reason is not OMITTED:
+        choice["finish_reason"] = finish_reason
+    payload: dict = {
+        "id": "chatcmpl-test",
+        "object": "chat.completion",
+        "created": 0,
+        "model": DEPLOYMENT,
+        "choices": [choice],
+    }
+    if usage is not None:
+        payload["usage"] = usage
+
+    conn = AzureOpenAIChatModelConnection(
+        api_key="test-key",
+        azure_endpoint="https://example.openai.azure.com";,
+        api_version="2024-08-01-preview",
+    )
+    mock_client = MagicMock()
+    mock_client.chat.completions.create.return_value = 
ChatCompletion.construct(
+        **payload
+    )
+    conn._client = mock_client
+    return conn
+
+
+def _chat(conn: AzureOpenAIChatModelConnection, **kwargs: object) -> 
ChatMessage:
+    return conn.chat(
+        [ChatMessage(role=MessageRole.USER, content="hi")], model=DEPLOYMENT, 
**kwargs
+    )
+
+
+def test_chat_records_finish_reason_in_extra_args() -> None:
+    """The finish reason survives alongside the token metrics."""
+    result = _chat(
+        _connection("length", usage=USAGE), model_of_azure_deployment="gpt-4o"
+    )
+
+    assert result.extra_args["promptTokens"] == 1
+    assert result.extra_args["finish_reason"] == "length"
+
+
+def test_chat_records_finish_reason_without_usage_or_deployment_model() -> 
None:
+    """The finish reason is captured independently of the token metrics.
+
+    The metrics branch needs both model_of_azure_deployment and a usage block;
+    neither is supplied here. promptTokens is asserted absent to confirm that
+    branch did not run, so the finish reason cannot have been recorded by it.
+    """
+    result = _chat(_connection("tool_calls"))
+
+    assert "promptTokens" not in result.extra_args
+    assert result.extra_args["finish_reason"] == "tool_calls"
+
+
+def test_chat_records_unrecognized_finish_reason_verbatim() -> None:
+    """A finish reason outside the documented set is stored as received."""
+    result = _chat(
+        _connection("some_vendor_reason", usage=USAGE),
+        model_of_azure_deployment="gpt-4o",
+    )
+
+    assert result.extra_args["finish_reason"] == "some_vendor_reason"
+
+
+def test_chat_records_empty_finish_reason() -> None:
+    """An empty finish reason is recorded rather than discarded."""
+    # The capture turns on the value being present, not on it being non-empty,
+    # so an empty reason reaches extra_args like any other string.
+    result = _chat(_connection("", usage=USAGE), 
model_of_azure_deployment="gpt-4o")
+
+    assert result.extra_args["finish_reason"] == ""
+
+
+def test_chat_omits_finish_reason_when_response_has_none() -> None:
+    """A response whose choice carries no finish reason yields no key."""
+    result = _chat(
+        _connection(OMITTED, usage=USAGE), model_of_azure_deployment="gpt-4o"
+    )
+
+    assert "finish_reason" not in result.extra_args
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 587418dde..2eb1bacbc 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
@@ -259,7 +259,8 @@ class OpenAIChatModelConnection(BaseChatModelConnection):
         Returns:
         -------
         ChatMessage
-            Model response message
+            Model response message. When the response carries a finish reason,
+            it is available as ``extra_args["finish_reason"]``.
         """
         tool_specs = None
         if tools is not None:
@@ -297,7 +298,11 @@ class OpenAIChatModelConnection(BaseChatModelConnection):
             extra_args["promptTokens"] = response.usage.prompt_tokens
             extra_args["completionTokens"] = response.usage.completion_tokens
 
-        message = response.choices[0].message
+        choice = response.choices[0]
+        if choice.finish_reason is not None:
+            extra_args["finish_reason"] = choice.finish_reason
+
+        message = choice.message
 
         return convert_from_openai_message(message, extra_args)
 
diff --git 
a/python/flink_agents/integrations/chat_models/openai/openai_utils.py 
b/python/flink_agents/integrations/chat_models/openai/openai_utils.py
index 9601d9ebc..0eec2b11b 100644
--- a/python/flink_agents/integrations/chat_models/openai/openai_utils.py
+++ b/python/flink_agents/integrations/chat_models/openai/openai_utils.py
@@ -136,6 +136,11 @@ def convert_to_openai_message(message: ChatMessage) -> 
ChatCompletionMessagePara
     - ASSISTANT role with tool_calls -> ChatCompletionAssistantMessageParam
     - USER role -> ChatCompletionUserMessageParam
     - SYSTEM role -> ChatCompletionSystemMessageParam
+
+    Only the fields OpenAI defines for each role are sent. Entries in
+    extra_args are not forwarded as message fields, except that a tool
+    message takes its tool_call_id from extra_args["external_id"], and an
+    assistant message carries extra_args["refusal"] when that value is a str.
     """
     role = message.role
 
@@ -145,7 +150,6 @@ def convert_to_openai_message(message: ChatMessage) -> 
ChatCompletionMessagePara
             "role": "system",
             "content": message.content,
         }
-        system_message.update(message.extra_args)
         return system_message
 
     # Handle USER role messages
@@ -154,7 +158,6 @@ def convert_to_openai_message(message: ChatMessage) -> 
ChatCompletionMessagePara
             "role": "user",
             "content": message.content,
         }
-        user_message.update(message.extra_args)
         return user_message
     # Handle ASSISTANT role messages
 
@@ -172,7 +175,9 @@ def convert_to_openai_message(message: ChatMessage) -> 
ChatCompletionMessagePara
             ]
             assistant_message["tool_calls"] = openai_tool_calls
 
-        assistant_message.update(message.extra_args)
+        refusal = message.extra_args.get("refusal")
+        if isinstance(refusal, str):
+            assistant_message["refusal"] = refusal
         return assistant_message
 
     # Handle TOOL role messages
diff --git 
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_response_parsing.py
 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_response_parsing.py
index 07c5e3d91..7ec8f384c 100644
--- 
a/python/flink_agents/integrations/chat_models/openai/tests/test_openai_response_parsing.py
+++ 
b/python/flink_agents/integrations/chat_models/openai/tests/test_openai_response_parsing.py
@@ -15,11 +15,18 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
+from unittest.mock import MagicMock
+
 import pytest
-from openai.types.chat import ChatCompletionMessage
+from openai.types.chat import ChatCompletion, ChatCompletionMessage
 
+from flink_agents.api.chat_message import ChatMessage, MessageRole
+from flink_agents.integrations.chat_models.openai.openai_chat_model import (
+    OpenAIChatModelConnection,
+)
 from flink_agents.integrations.chat_models.openai.openai_utils import (
     convert_from_openai_message,
+    convert_to_openai_message,
 )
 
 
@@ -43,10 +50,200 @@ def test_refusal_is_preserved_in_extra_args(refusal: str) 
-> None:
 
 def test_no_refusal_key_when_refusal_absent() -> None:
     """A response that was not refused leaves no refusal key behind."""
-    # extra_args is merged back into the outbound assistant message, so a null
-    # refusal key here would be echoed to the provider on every later request.
+    # Absence of the key, not a falsy value, is what marks a response that was
+    # never refused.
     message = ChatCompletionMessage(role="assistant", content="ok", 
refusal=None)
 
     result = convert_from_openai_message(message, {})
 
     assert "refusal" not in result.extra_args
+
+
[email protected](
+    "role", [MessageRole.SYSTEM, MessageRole.USER, MessageRole.ASSISTANT]
+)
+def test_convert_to_openai_message_omits_response_metadata(
+    role: MessageRole,
+) -> None:
+    """Completion metadata held in extra_args never reaches an outbound 
param."""
+    message = ChatMessage(
+        role=role,
+        content="hello",
+        extra_args={
+            "model_name": "gpt-4o",
+            "promptTokens": 3,
+            "completionTokens": 5,
+        },
+    )
+
+    param = convert_to_openai_message(message)
+
+    assert param == {"role": role.value, "content": "hello"}
+
+
[email protected]("refusal", ["I cannot help with that", ""])
+def test_convert_to_openai_message_forwards_string_refusal(refusal: str) -> 
None:
+    """A string refusal on an assistant message is sent to the provider."""
+    # The outbound guard is a type check rather than a truthiness check, so an
+    # empty reason forwards like any other.
+    message = ChatMessage(
+        role=MessageRole.ASSISTANT,
+        content="",
+        extra_args={"refusal": refusal},
+    )
+
+    param = convert_to_openai_message(message)
+
+    assert param == {"role": "assistant", "content": "", "refusal": refusal}
+
+
[email protected]("refusal", [{"reason": "policy"}, 123, True])
+def test_convert_to_openai_message_omits_non_string_refusal(
+    refusal: object,
+) -> None:
+    """Only a string refusal is forwarded; a value of any other type is 
dropped."""
+    message = ChatMessage(
+        role=MessageRole.ASSISTANT,
+        content="",
+        extra_args={"refusal": refusal},
+    )
+
+    param = convert_to_openai_message(message)
+
+    assert param == {"role": "assistant", "content": ""}
+
+
+def test_convert_to_openai_message_assistant_tool_calls() -> None:
+    """An assistant message requesting tool calls sends them with a null 
content."""
+    message = ChatMessage(
+        role=MessageRole.ASSISTANT,
+        content="",
+        tool_calls=[
+            {
+                "original_id": "call_abc",
+                "function": {"name": "get_weather", "arguments": {"city": 
"Berlin"}},
+            }
+        ],
+        extra_args={"model_name": "gpt-4o", "promptTokens": 3},
+    )
+
+    param = convert_to_openai_message(message)
+
+    assert set(param) == {"role", "content", "tool_calls"}
+    assert param["role"] == "assistant"
+    assert param["content"] is None
+
+
+def test_convert_to_openai_message_tool_role_unchanged() -> None:
+    """A tool result carries its call id and nothing else from extra_args."""
+    message = ChatMessage(
+        role=MessageRole.TOOL,
+        content="42",
+        extra_args={"external_id": "call_abc", "promptTokens": 7},
+    )
+
+    param = convert_to_openai_message(message)
+
+    assert param == {"role": "tool", "content": "42", "tool_call_id": 
"call_abc"}
+
+
+ASSISTANT_MESSAGE = {
+    "role": "assistant",
+    "content": "ok",
+    "tool_calls": None,
+    "refusal": None,
+}
+USAGE = {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
+
+OMITTED = object()
+"""Sentinel for a response whose choice carries no finish_reason field at all,
+which is distinct from one carrying an explicit null."""
+
+
+def _connection(
+    finish_reason: object, usage: dict | None = None
+) -> OpenAIChatModelConnection:
+    """Build a connection whose stubbed transport returns one real completion.
+
+    The transport is a mock but the payload is a genuine ``ChatCompletion``, so
+    the assertions exercise the SDK's own attribute access rather than values a
+    mock was told to return.
+
+    Parameters
+    ----------
+    finish_reason : object
+        Value for the choice's ``finish_reason``. Pass ``OMITTED`` to leave the
+        field out of the payload entirely. Required, so a caller cannot omit it
+        by accident and assert against an unintended shape.
+    usage : dict | None
+        Token usage block, or None to build a response carrying none.
+    """
+    choice: dict = {"index": 0, "message": ASSISTANT_MESSAGE}
+    if finish_reason is not OMITTED:
+        choice["finish_reason"] = finish_reason
+    payload: dict = {
+        "id": "chatcmpl-test",
+        "object": "chat.completion",
+        "created": 0,
+        "model": "gpt-4o",
+        "choices": [choice],
+    }
+    if usage is not None:
+        payload["usage"] = usage
+
+    conn = OpenAIChatModelConnection(
+        api_key="test-key", api_base_url="http://localhost";
+    )
+    mock_client = MagicMock()
+    mock_client.chat.completions.create.return_value = 
ChatCompletion.construct(
+        **payload
+    )
+    conn._client = mock_client
+    return conn
+
+
+def _chat(conn: OpenAIChatModelConnection) -> ChatMessage:
+    return conn.chat([ChatMessage(role=MessageRole.USER, content="hi")], 
model="gpt-4o")
+
+
+def test_chat_records_finish_reason_in_extra_args() -> None:
+    """The finish reason survives alongside the token metrics."""
+    result = _chat(_connection("length", usage=USAGE))
+
+    assert result.extra_args["promptTokens"] == 1
+    assert result.extra_args["finish_reason"] == "length"
+
+
+def test_chat_records_finish_reason_when_usage_is_absent() -> None:
+    """The finish reason is captured independently of the token metrics.
+
+    promptTokens is asserted absent to confirm the metrics branch did not run,
+    so the finish reason cannot have been recorded by it.
+    """
+    result = _chat(_connection("tool_calls"))
+
+    assert "promptTokens" not in result.extra_args
+    assert result.extra_args["finish_reason"] == "tool_calls"
+
+
+def test_chat_records_unrecognized_finish_reason_verbatim() -> None:
+    """A finish reason outside the documented set is stored as received."""
+    result = _chat(_connection("some_vendor_reason", usage=USAGE))
+
+    assert result.extra_args["finish_reason"] == "some_vendor_reason"
+
+
+def test_chat_records_empty_finish_reason() -> None:
+    """An empty finish reason is recorded rather than discarded."""
+    # The capture turns on the value being present, not on it being non-empty,
+    # so an empty reason reaches extra_args like any other string.
+    result = _chat(_connection("", usage=USAGE))
+
+    assert result.extra_args["finish_reason"] == ""
+
+
+def test_chat_omits_finish_reason_when_response_has_none() -> None:
+    """A response whose choice carries no finish reason yields no key."""
+    result = _chat(_connection(OMITTED, usage=USAGE))
+
+    assert "finish_reason" not in result.extra_args
diff --git a/python/flink_agents/plan/actions/chat_model_action.py 
b/python/flink_agents/plan/actions/chat_model_action.py
index 0b6e7f4ae..b802fda3b 100644
--- a/python/flink_agents/plan/actions/chat_model_action.py
+++ b/python/flink_agents/plan/actions/chat_model_action.py
@@ -57,6 +57,9 @@ _TOOL_CALL_CONTEXT = "_TOOL_CALL_CONTEXT"
 _TOOL_REQUEST_EVENT_CONTEXT = "_TOOL_REQUEST_EVENT_CONTEXT"
 _RETRY_STATS_CONTEXT = "_RETRY_STATS_CONTEXT"
 _PROMPT_ARGS = "prompt_args"
+_FINISH_REASON = "finish_reason"
+_TRUNCATED_FINISH_REASON = "length"
+_CONTENT_FILTERED_FINISH_REASON = "content_filter"
 
 _logger = logging.getLogger(__name__)
 
@@ -272,6 +275,38 @@ def _generate_structured_output(
     return response
 
 
+def _reject_incomplete_response(response: ChatMessage) -> None:
+    """Reject a response the provider did not finish emitting.
+
+    Evaluated once per chat response, before it is dispatched as text,
+    structured output, or tool calls.
+
+    Args:
+        response: The chat response whose ``finish_reason`` is inspected. Any
+            other reason, and an absent one, are accepted.
+
+    Raises:
+        ValueError: If the finish reason reports the response as cut off by the
+            token budget or withheld by content filtering.
+    """
+    finish_reason = response.extra_args.get(_FINISH_REASON)
+    if finish_reason == _TRUNCATED_FINISH_REASON:
+        error_message = (
+            f"ChatModel response is truncated 
(finish_reason={finish_reason!r}): "
+            "it exhausted the completion token budget before the model 
finished, "
+            "so the content is incomplete. Raise the model's max output 
tokens, "
+            "or ask for a smaller output."
+        )
+        raise ValueError(error_message)
+    if finish_reason == _CONTENT_FILTERED_FINISH_REASON:
+        error_message = (
+            "ChatModel response was withheld by the provider's content filter "
+            f"(finish_reason={finish_reason!r}), so the content is incomplete. 
"
+            "Adjust the prompt or the provider's content filtering 
configuration."
+        )
+        raise ValueError(error_message)
+
+
 def _generate_structured_output_with_report(
     ctx: RunnerContext, response: ChatMessage, output_schema: OutputSchema
 ) -> ChatMessage:
@@ -393,6 +428,10 @@ async def chat(
                     response.extra_args["completionTokens"],
                     request_metric_group,
                 )
+            # A truncated response consumed its full token budget, so the token
+            # metrics above are recorded before this rejects and abandons the
+            # response.
+            _reject_incomplete_response(response)
             if output_schema is not None and len(response.tool_calls) == 0:
                 response = _generate_structured_output_with_report(
                     ctx, response, output_schema
diff --git a/python/flink_agents/plan/tests/actions/test_chat_model_action.py 
b/python/flink_agents/plan/tests/actions/test_chat_model_action.py
index f0bba58e4..42c42b3e5 100644
--- a/python/flink_agents/plan/tests/actions/test_chat_model_action.py
+++ b/python/flink_agents/plan/tests/actions/test_chat_model_action.py
@@ -15,6 +15,7 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
#################################################################################
+from unittest.mock import MagicMock
 from uuid import uuid4
 
 from pydantic import BaseModel
@@ -23,10 +24,12 @@ from pyflink.common.typeinfo import BasicTypeInfo, 
RowTypeInfo
 from flink_agents.api.agents.react_agent import OutputSchema
 from flink_agents.api.chat_message import ChatMessage, MessageRole
 from flink_agents.api.memory_object import MemoryType
+from flink_agents.api.trace import ExecutionReporter
 from flink_agents.plan.actions.chat_model_action import (
     _TOOL_CALL_CONTEXT,
     _TOOL_REQUEST_EVENT_CONTEXT,
     _clean_llm_response,
+    _generate_structured_output_with_report,
     _get_tool_request_event_context,
     _save_tool_request_event_context,
     _update_tool_call_context,
@@ -207,3 +210,30 @@ def test_save_get_preserves_model_and_prompt_args():
     context = _get_tool_request_event_context(mem, event_id)
     assert context["model"] == "ollama"
     assert context["prompt_args"] == prompt_args
+
+
+_PARSEABLE_CONTENT = '{"result": 42}'
+
+
+def _response(extra_args) -> ChatMessage:
+    return ChatMessage(
+        role=MessageRole.ASSISTANT,
+        content=_PARSEABLE_CONTENT,
+        extra_args=extra_args,
+    )
+
+
+def _parse(ctx, extra_args) -> ChatMessage:
+    return _generate_structured_output_with_report(
+        ctx, _response(extra_args), OutputSchema(output_schema=_Result)
+    )
+
+
+def test_accepted_finish_reason_reports_parser_execution():
+    ctx = MagicMock(spec=ExecutionReporter)
+
+    _parse(ctx, {"finish_reason": "stop"})
+
+    ctx.report_execution_started.assert_called_once()
+    ctx.report_execution_succeeded.assert_called_once()
+    ctx.report_execution_failed.assert_not_called()
diff --git 
a/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py 
b/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py
index f73648aa8..a5cad1a34 100644
--- a/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py
+++ b/python/flink_agents/plan/tests/actions/test_chat_model_action_retry.py
@@ -34,7 +34,7 @@ from flink_agents.api.core_options import (
     ErrorHandlingStrategy,
 )
 from flink_agents.api.events.chat_event import ChatResponseEvent
-from flink_agents.api.events.tool_event import ToolResponseEvent
+from flink_agents.api.events.tool_event import ToolRequestEvent, 
ToolResponseEvent
 from flink_agents.api.metric_group import Counter, MetricGroup
 from flink_agents.api.trace import (
     ExecutionEntityTypes,
@@ -119,6 +119,7 @@ def _create_mock_runner_context(
     chat_model: Any,
     max_retries: int = 3,
     retry_wait_interval_sec: int = 1,
+    error_handling_strategy: ErrorHandlingStrategy = 
ErrorHandlingStrategy.RETRY,
 ) -> tuple[MagicMock, list, _MockMetricGroup, _MockMemoryObject]:
     """Create a mock RunnerContext with configurable retry settings.
 
@@ -131,7 +132,7 @@ def _create_mock_runner_context(
 
     config = MagicMock()
     option_values = {
-        id(AgentExecutionOptions.ERROR_HANDLING_STRATEGY): 
ErrorHandlingStrategy.RETRY,
+        id(AgentExecutionOptions.ERROR_HANDLING_STRATEGY): 
error_handling_strategy,
         id(AgentExecutionOptions.MAX_RETRIES): max_retries,
         id(AgentExecutionOptions.RETRY_WAIT_INTERVAL): retry_wait_interval_sec,
         id(AgentExecutionOptions.CHAT_ASYNC): False,
@@ -343,6 +344,204 @@ class TestChatModelActionRetry:
         )
 
 
+class TestChatModelActionFinishReason:
+    """Tests for the finish-reason gate on the common chat-response path."""
+
+    def _run(self, ctx, output_schema=None) -> None:
+        asyncio.run(
+            chat(
+                uuid4(),
+                "test-model",
+                [ChatMessage(role=MessageRole.USER, content="hi")],
+                {},
+                output_schema,
+                ctx,
+            )
+        )
+
+    def test_truncated_text_response_rejected(self) -> None:
+        chat_model = MagicMock()
+        chat_model.chat = MagicMock(
+            return_value=ChatMessage(
+                role=MessageRole.ASSISTANT,
+                content="partial answ",
+                extra_args={"finish_reason": "length"},
+            )
+        )
+        ctx, sent_events, _, _ = _create_mock_runner_context(
+            chat_model, max_retries=0, retry_wait_interval_sec=0
+        )
+
+        with pytest.raises(ValueError, match="(?i)truncat") as exc_info:
+            self._run(ctx)
+
+        assert "token" in str(exc_info.value).lower()
+        assert len(sent_events) == 0
+
+    def test_content_filtered_text_response_rejected(self) -> None:
+        # Matches a word unique to the filtering message. Both messages
+        # interpolate the finish reason, so "content_filter" appears in either
+        # one and cannot tell them apart.
+        chat_model = MagicMock()
+        chat_model.chat = MagicMock(
+            return_value=ChatMessage(
+                role=MessageRole.ASSISTANT,
+                content="",
+                extra_args={"finish_reason": "content_filter"},
+            )
+        )
+        ctx, sent_events, _, _ = _create_mock_runner_context(
+            chat_model, max_retries=0, retry_wait_interval_sec=0
+        )
+
+        with pytest.raises(ValueError, match="(?i)withheld"):
+            self._run(ctx)
+
+        assert len(sent_events) == 0
+
+    def test_truncated_tool_call_response_rejected_before_tool_dispatch(self) 
-> None:
+        chat_model = MagicMock()
+        chat_model.chat = MagicMock(
+            return_value=ChatMessage(
+                role=MessageRole.ASSISTANT,
+                content="",
+                tool_calls=[
+                    {
+                        "id": "call-1",
+                        "function": {"name": "f", "arguments": ""},
+                    }
+                ],
+                extra_args={"finish_reason": "length"},
+            )
+        )
+        ctx, sent_events, _, _ = _create_mock_runner_context(
+            chat_model, max_retries=0, retry_wait_interval_sec=0
+        )
+
+        with pytest.raises(ValueError, match="(?i)truncat"):
+            self._run(ctx)
+
+        # A truncated tool call carries arguments the model never finished
+        # writing, so no ToolRequestEvent may leave the action.
+        assert len(sent_events) == 0
+
+    @pytest.mark.parametrize(
+        "extra_args",
+        [
+            {"finish_reason": "stop"},
+            {"finish_reason": "tool_calls"},
+            {"finish_reason": "some_vendor_reason"},
+            {},
+        ],
+        ids=["stop", "tool_calls", "unrecognized", "absent"],
+    )
+    def test_accepted_finish_reason_reaches_the_response_event(
+        self, extra_args: dict
+    ) -> None:
+        chat_model = MagicMock()
+        chat_model.chat = MagicMock(
+            return_value=ChatMessage(
+                role=MessageRole.ASSISTANT,
+                content="hello",
+                extra_args=extra_args,
+            )
+        )
+        ctx, sent_events, _, _ = _create_mock_runner_context(
+            chat_model, max_retries=0, retry_wait_interval_sec=0
+        )
+
+        self._run(ctx)
+
+        assert len(sent_events) == 1
+        assert isinstance(sent_events[0], ChatResponseEvent)
+        assert sent_events[0].response.content == "hello"
+
+    def test_accepted_finish_reason_dispatches_tool_request_event(self) -> 
None:
+        # A response carrying tool calls passes the same finish-reason gate as 
a
+        # text response, so an accepted reason must reach tool dispatch.
+        tool_calls = [{"id": "call-1", "function": {"name": "f", "arguments": 
{}}}]
+        chat_model = MagicMock()
+        chat_model.chat = MagicMock(
+            return_value=ChatMessage(
+                role=MessageRole.ASSISTANT,
+                content="",
+                tool_calls=tool_calls,
+                extra_args={"finish_reason": "tool_calls"},
+            )
+        )
+        ctx, sent_events, _, _ = _create_mock_runner_context(
+            chat_model, max_retries=0, retry_wait_interval_sec=0
+        )
+
+        self._run(ctx)
+
+        assert len(sent_events) == 1
+        assert isinstance(sent_events[0], ToolRequestEvent)
+        assert sent_events[0].tool_calls == tool_calls
+
+    def test_ignore_strategy_drops_rejected_response_without_event(self) -> 
None:
+        # Under IGNORE the record is dropped: the rejection does not propagate
+        # and no event carries the truncated content downstream.
+        chat_model = MagicMock()
+        chat_model.chat = MagicMock(
+            return_value=ChatMessage(
+                role=MessageRole.ASSISTANT,
+                content="partial answ",
+                extra_args={"finish_reason": "length"},
+            )
+        )
+        ctx, sent_events, _, _ = _create_mock_runner_context(
+            chat_model,
+            max_retries=0,
+            retry_wait_interval_sec=0,
+            error_handling_strategy=ErrorHandlingStrategy.IGNORE,
+        )
+
+        self._run(ctx)
+
+        assert len(sent_events) == 0
+
+    @pytest.mark.parametrize("finish_reason", ["length", "content_filter"])
+    def test_rejected_finish_reason_skips_structured_output(
+        self, finish_reason: str
+    ) -> None:
+        chat_model = MagicMock()
+        chat_model.chat = MagicMock(
+            return_value=ChatMessage(
+                role=MessageRole.ASSISTANT,
+                content='{"result": 42}',
+                extra_args={
+                    "finish_reason": finish_reason,
+                    "model_name": "provider-model",
+                    "promptTokens": 100,
+                    "completionTokens": 50,
+                },
+            )
+        )
+        ctx, sent_events, metric_group, _ = _create_mock_runner_context(
+            chat_model, max_retries=0, retry_wait_interval_sec=0
+        )
+
+        with pytest.raises(ValueError):
+            self._run(ctx, OutputSchema(output_schema=_StructuredResult))
+
+        # The model call itself succeeded and spent its full token budget, so
+        # both must be recorded before the response is rejected.
+        ctx.report_execution_succeeded.assert_called_once_with(
+            ExecutionEntityTypes.LLM, "test-model", _LLM_METADATA
+        )
+        chat_model._record_token_metrics.assert_called_once_with(
+            "provider-model", 100, 50, metric_group
+        )
+        # The parse is never attempted, so nothing about it is reported and no
+        # response leaves the action.
+        ctx.report_execution_started.assert_called_once_with(
+            ExecutionEntityTypes.LLM, "test-model", _LLM_METADATA
+        )
+        ctx.report_execution_failed.assert_not_called()
+        assert len(sent_events) == 0
+
+
 class TestChatResponseEventRetryFields:
     """Tests for ChatResponseEvent retry fields."""
 

Reply via email to