This is an automated email from the ASF dual-hosted git repository.

Mryange pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new 8dd6aafade8 [Fix](ai_func) Parse final text from Responses API output 
(#67671)
8dd6aafade8 is described below

commit 8dd6aafade836c862730d66449c5e3a70c6f8f69
Author: linrrarity <[email protected]>
AuthorDate: Thu Sep 10 14:39:15 2026 +0800

    [Fix](ai_func) Parse final text from Responses API output (#67671)
    
    ### What problem does this PR solve?
    
    Issue Number: close #xxx
    
    Related PR: #xxx
    
    Problem Summary:
    
    The Responses API returns heterogeneous items in the `output` array.
    Besides the final `message`, it may contain `reasoning` and tool-related
    items.
    
    The previous OpenAI adapter treated every output item as response text.
    This caused two problems:
    
    1. An OpenAI-type reasoning item with an empty `content` array was
    reported as an invalid response.
    2. An OpenAI-type `reasoning_text` item was incorrectly included in
    batch results, causing errors such as `expected 1 items but got 2`.
    
    This change updates the Responses API parser to:
    
    - Ignore non-`message` output items, including reasoning and tool items.
    - Only extract `output_text` parts from message content.
    - Preserve validation for malformed message and output-text structures.
    - Update the response format comments to match the current API
    structure.
    
    for example:
    ```sql
    CREATE RESOURCE 'deepseek-responses'
    PROPERTIES (
      'type'='ai',
      'ai.provider_type'='deepseek',
      'ai.endpoint'='https://api.deepseek.com/responses',
      'ai.model_name' = 'deepseek-v4-flash',
      'ai.api_key' = 'sk-xxx'
    );
    
    
    SELECT id, ai_TRANSLATE('deepseek-responses', str_val, tar_language) AS 
Result
    FROM ai_test WHERE str_val IS NOT NULL;
    ```
    
    before:
    ```text
    Doris> SELECT id, ai_TRANSLATE('deepseek-responses', str_val, tar_language) 
AS Result
        -> FROM ai_test WHERE str_val IS NOT NULL;
    ERROR 1105 (HY000): Exception, msg: (127.0.0.1)[RUNTIME_ERROR]Failed to 
parse ai_translate batch result, expected 1 items but got 2
    ```
    
    
    now
    ```text
    
+------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
    | id   | Result                                                             
                                                                                
                                                                                
    |
    
+------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
    |    4 | Satz 4, Apache Doris ist ein MPP-basiertes 
Echtzeit-Data-Warehouse, das für seine hohe Abfragegeschwindigkeit bekannt ist. 
                                                                                
                            |
    |    3 | phrase 3, Apache Doris est un entrepôt de données en temps réel 
basé sur MPP, connu pour sa vitesse de requête élevée.                          
                                                                                
       |
    |    6 | предложение 6, Apache Doris — это хранилище данных реального 
времени на основе MPP, известное высокой скоростью выполнения запросов.         
                                                                                
          |
    |    2 | 句子2:Apache Doris 是一个基于 MPP 的实时数据仓库,以高查询速度著称。                       
                                                                                
                                                           |
    |    5 | 文5、Apache DorisはMPPベースのリアルタイムデータウェアハウスであり、高速なクエリ処理で知られています。        
                                                                                
                                         |
    |    8 | Frase 8, Apache Doris é um data warehouse em tempo real baseado em 
MPP, conhecido por sua alta velocidade de consulta.                             
                                                                                
    |
    |    9 | 문장 9, Apache Doris는 높은 쿼리 속도로 알려진 MPP 기반 실시간 데이터 웨어하우스입니다.         
                                                                                
                                                       |
    |    1 | sentence 1, Apache Doris is an MPP-based real-time data warehouse 
known for its high query speed.                                                 
                                                                                
     |
    |    7 | oración 7, Apache Doris es un almacén de datos en tiempo real 
basado en MPP conocido por su alta velocidad de consulta.                       
                                                                                
         |
    |   10 | الجملة 10، Apache Doris هو مستودع بيانات في الوقت الفعلي يعتمد على 
MPP ويُعرف بسرعته العالية في معالجة الاستعلامات.                                
                                                                                
    |
    
+------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
    ```
    
    ### Release note
    
    Fix parsing of OpenAI-compatible Responses API results containing
    reasoning output.
---
 be/src/exprs/aggregate/aggregate_function_ai_agg.h |  12 +-
 be/src/exprs/function/ai/ai_adapter.h              | 141 ++++++++---
 be/test/ai/aggregate_function_ai_agg_test.cpp      |  45 +++-
 be/test/ai/ai_adapter_test.cpp                     | 263 ++++++++++++++++++++-
 4 files changed, 422 insertions(+), 39 deletions(-)

diff --git a/be/src/exprs/aggregate/aggregate_function_ai_agg.h 
b/be/src/exprs/aggregate/aggregate_function_ai_agg.h
index cc0d67f9821..d3da899fca8 100644
--- a/be/src/exprs/aggregate/aggregate_function_ai_agg.h
+++ b/be/src/exprs/aggregate/aggregate_function_ai_agg.h
@@ -19,6 +19,7 @@
 
 #include <gen_cpp/PaloInternalService_types.h>
 
+#include <cstdlib>
 #include <memory>
 
 #include "common/status.h"
@@ -118,9 +119,13 @@ public:
         THROW_IF_ERROR(
                 _ai_adapter->build_request_payload(inputs, 
system_prompt.c_str(), request_body));
         THROW_IF_ERROR(send_request_to_ai(request_body, response));
-        THROW_IF_ERROR(_ai_adapter->parse_response(response, results));
+        THROW_IF_ERROR(_ai_adapter->parse_response(response, results, false /* 
expand_batch */));
 
-        return results[0];
+        if (results.size() != 1) [[unlikely]] {
+            throw Exception(ErrorCode::INTERNAL_ERROR,
+                            "AI aggregate expected one result but got {}", 
results.size());
+        }
+        return results.front();
     }
 
     // init task and ai related parameters
@@ -159,7 +164,8 @@ private:
     Status send_request_to_ai(const std::string& request_body, std::string& 
response) const {
         // Mock path for testing
 #ifdef BE_TEST
-        response = "this is a mock response";
+        const char* test_result = std::getenv("AI_TEST_RESULT");
+        response = test_result != nullptr ? test_result : "this is a mock 
response";
         return Status::OK();
 #endif
 
diff --git a/be/src/exprs/function/ai/ai_adapter.h 
b/be/src/exprs/function/ai/ai_adapter.h
index 39f16e73420..b6376c6c004 100644
--- a/be/src/exprs/function/ai/ai_adapter.h
+++ b/be/src/exprs/function/ai/ai_adapter.h
@@ -24,6 +24,7 @@
 #include <cctype>
 #include <memory>
 #include <string>
+#include <string_view>
 #include <unordered_map>
 #include <vector>
 
@@ -133,7 +134,8 @@ public:
 
     // Parse response from AI service and extract generated text results
     virtual Status parse_response(const std::string& response_body,
-                                  std::vector<std::string>& results) const {
+                                  std::vector<std::string>& results,
+                                  bool /* expand_batch */ = true) const {
         return Status::NotSupported("{} don't support text generation", 
_config.provider_type);
     }
 
@@ -171,8 +173,15 @@ protected:
     // Example:
     // provider response -> choices[0].message.content = "[\"1\",\"0\",\"1\"]"
     // this helper       -> appends "1", "0", "1" into `results`
+    // Set expand_batch to false when AI_AGG needs the complete generated text 
as one result.
     static Status append_parsed_text_result(std::string_view text,
-                                            std::vector<std::string>& results) 
{
+                                            std::vector<std::string>& results,
+                                            bool expand_batch = true) {
+        if (!expand_batch) {
+            results.emplace_back(text.data(), text.size());
+            return Status::OK();
+        }
+
         size_t begin = 0;
         size_t end = text.size();
         while (begin < end && std::isspace(static_cast<unsigned 
char>(text[begin]))) {
@@ -455,8 +464,8 @@ public:
         return Status::OK();
     }
 
-    Status parse_response(const std::string& response_body,
-                          std::vector<std::string>& results) const override {
+    Status parse_response(const std::string& response_body, 
std::vector<std::string>& results,
+                          bool expand_batch = true) const override {
         rapidjson::Document doc;
         doc.Parse(response_body.c_str());
 
@@ -475,26 +484,29 @@ public:
                 if (choices[i].HasMember("message") && 
choices[i]["message"].HasMember("content") &&
                     choices[i]["message"]["content"].IsString()) {
                     RETURN_IF_ERROR(append_parsed_text_result(
-                            choices[i]["message"]["content"].GetString(), 
results));
+                            choices[i]["message"]["content"].GetString(), 
results, expand_batch));
                 } else if (choices[i].HasMember("text") && 
choices[i]["text"].IsString()) {
                     // Some local LLMs use a simpler format
-                    RETURN_IF_ERROR(
-                            
append_parsed_text_result(choices[i]["text"].GetString(), results));
+                    
RETURN_IF_ERROR(append_parsed_text_result(choices[i]["text"].GetString(),
+                                                              results, 
expand_batch));
                 }
             }
         } else if (doc.HasMember("text") && doc["text"].IsString()) {
             // Format 2: Simple response with just "text" or "content" field
-            RETURN_IF_ERROR(append_parsed_text_result(doc["text"].GetString(), 
results));
+            RETURN_IF_ERROR(
+                    append_parsed_text_result(doc["text"].GetString(), 
results, expand_batch));
         } else if (doc.HasMember("content") && doc["content"].IsString()) {
-            
RETURN_IF_ERROR(append_parsed_text_result(doc["content"].GetString(), results));
+            RETURN_IF_ERROR(
+                    append_parsed_text_result(doc["content"].GetString(), 
results, expand_batch));
         } else if (doc.HasMember("response") && doc["response"].IsString()) {
             // Format 3: Response field (Ollama `generate` format)
-            
RETURN_IF_ERROR(append_parsed_text_result(doc["response"].GetString(), 
results));
+            RETURN_IF_ERROR(
+                    append_parsed_text_result(doc["response"].GetString(), 
results, expand_batch));
         } else if (doc.HasMember("message") && doc["message"].IsObject() &&
                    doc["message"].HasMember("content") && 
doc["message"]["content"].IsString()) {
             // Format 4: message/content field (Ollama `chat` format)
-            RETURN_IF_ERROR(
-                    
append_parsed_text_result(doc["message"]["content"].GetString(), results));
+            
RETURN_IF_ERROR(append_parsed_text_result(doc["message"]["content"].GetString(),
+                                                      results, expand_batch));
         } else {
             return Status::NotSupported("Unsupported response format from 
local AI.");
         }
@@ -807,8 +819,8 @@ public:
         return Status::OK();
     }
 
-    Status parse_response(const std::string& response_body,
-                          std::vector<std::string>& results) const override {
+    Status parse_response(const std::string& response_body, 
std::vector<std::string>& results,
+                          bool expand_batch = true) const override {
         rapidjson::Document doc;
         doc.Parse(response_body.c_str());
 
@@ -817,37 +829,101 @@ public:
                                          response_body);
         }
 
-        if (doc.HasMember("output") && doc["output"].IsArray()) {
+        const bool is_responses_response =
+                doc.HasMember("output") ||
+                (doc.HasMember("object") && doc["object"].IsString() &&
+                 std::string_view(doc["object"].GetString(), 
doc["object"].GetStringLength()) ==
+                         "response");
+        if (is_responses_response) {
             /// for responses endpoint
             /*{
               "output": [
+                {
+                  "id": "rs_123",
+                  "type": "reasoning",
+                  "content": [],
+                  "summary": []
+                },
                 {
                   "id": "msg_123",
                   "type": "message",
                   "role": "assistant",
                   "content": [
                     {
-                      "type": "text",
+                      "type": "output_text",
                       "text": "result text here"   <- result
                     }
                   ]
                 }
               ]
             }*/
+            if (doc.HasMember("status")) {
+                if (!doc["status"].IsString()) {
+                    return Status::InternalError("Invalid status in {} 
response: {}",
+                                                 _config.provider_type, 
response_body);
+                }
+                if (std::string_view(doc["status"].GetString(), 
doc["status"].GetStringLength()) !=
+                    "completed") {
+                    return Status::InternalError("{} response is not 
completed: {}",
+                                                 _config.provider_type, 
response_body);
+                }
+            }
+
+            if (!doc.HasMember("output") || !doc["output"].IsArray()) {
+                return Status::InternalError("Invalid output format in {} 
response: {}",
+                                             _config.provider_type, 
response_body);
+            }
+
             const auto& output = doc["output"];
-            results.reserve(output.Size());
+            std::string response_text;
+            bool has_output_text = false;
 
             for (rapidjson::SizeType i = 0; i < output.Size(); i++) {
-                if (!output[i].HasMember("content") || 
!output[i]["content"].IsArray() ||
-                    output[i]["content"].Empty() || 
!output[i]["content"][0].HasMember("text") ||
-                    !output[i]["content"][0]["text"].IsString()) {
+                const auto& item = output[i];
+                if (!item.IsObject() || !item.HasMember("type") || 
!item["type"].IsString()) {
                     return Status::InternalError("Invalid output format in {} 
response: {}",
                                                  _config.provider_type, 
response_body);
                 }
 
-                RETURN_IF_ERROR(append_parsed_text_result(
-                        output[i]["content"][0]["text"].GetString(), results));
+                // Responses output is heterogeneous. Reasoning and tool items 
are not final text.
+                if (std::string_view(item["type"].GetString(), 
item["type"].GetStringLength()) !=
+                    "message") {
+                    continue;
+                }
+
+                if (!item.HasMember("content") || !item["content"].IsArray()) {
+                    return Status::InternalError("Invalid output format in {} 
response: {}",
+                                                 _config.provider_type, 
response_body);
+                }
+
+                const auto& content = item["content"];
+                for (rapidjson::SizeType j = 0; j < content.Size(); j++) {
+                    const auto& part = content[j];
+                    if (!part.IsObject() || !part.HasMember("type") || 
!part["type"].IsString()) {
+                        return Status::InternalError("Invalid output format in 
{} response: {}",
+                                                     _config.provider_type, 
response_body);
+                    }
+
+                    if (std::string_view(part["type"].GetString(),
+                                         part["type"].GetStringLength()) != 
"output_text") {
+                        continue;
+                    }
+
+                    if (!part.HasMember("text") || !part["text"].IsString()) {
+                        return Status::InternalError("Invalid output format in 
{} response: {}",
+                                                     _config.provider_type, 
response_body);
+                    }
+
+                    has_output_text = true;
+                    response_text.append(part["text"].GetString(), 
part["text"].GetStringLength());
+                }
+            }
+
+            if (!has_output_text) {
+                return Status::InternalError("No output text in {} response: 
{}",
+                                             _config.provider_type, 
response_body);
             }
+            RETURN_IF_ERROR(append_parsed_text_result(response_text, results, 
expand_batch));
         } else if (doc.HasMember("choices") && doc["choices"].IsArray()) {
             /// for completions endpoint
             /*{
@@ -877,7 +953,7 @@ public:
                 }
 
                 RETURN_IF_ERROR(append_parsed_text_result(
-                        choices[i]["message"]["content"].GetString(), 
results));
+                        choices[i]["message"]["content"].GetString(), results, 
expand_batch));
             }
         } else {
             return Status::InternalError("Invalid {} response format: {}", 
_config.provider_type,
@@ -1216,8 +1292,8 @@ public:
         return Status::OK();
     }
 
-    Status parse_response(const std::string& response_body,
-                          std::vector<std::string>& results) const override {
+    Status parse_response(const std::string& response_body, 
std::vector<std::string>& results,
+                          bool expand_batch = true) const override {
         rapidjson::Document doc;
         doc.Parse(response_body.c_str());
 
@@ -1258,7 +1334,8 @@ public:
             }
 
             RETURN_IF_ERROR(append_parsed_text_result(
-                    candidates[i]["content"]["parts"][0]["text"].GetString(), 
results));
+                    candidates[i]["content"]["parts"][0]["text"].GetString(), 
results,
+                    expand_batch));
         }
         return Status::OK();
     }
@@ -1522,8 +1599,8 @@ public:
         return Status::OK();
     }
 
-    Status parse_response(const std::string& response_body,
-                          std::vector<std::string>& results) const override {
+    Status parse_response(const std::string& response_body, 
std::vector<std::string>& results,
+                          bool expand_batch = true) const override {
         rapidjson::Document doc;
         doc.Parse(response_body.c_str());
         if (doc.HasParseError() || !doc.IsObject()) {
@@ -1561,7 +1638,7 @@ public:
             }
         }
 
-        return append_parsed_text_result(result, results);
+        return append_parsed_text_result(result, results, expand_batch);
     }
 };
 
@@ -1584,9 +1661,9 @@ public:
         return Status::OK();
     }
 
-    Status parse_response(const std::string& response_body,
-                          std::vector<std::string>& results) const override {
-        return append_parsed_text_result(response_body, results);
+    Status parse_response(const std::string& response_body, 
std::vector<std::string>& results,
+                          bool expand_batch = true) const override {
+        return append_parsed_text_result(response_body, results, expand_batch);
     }
 
     Status build_embedding_request(const std::vector<std::string>& inputs,
diff --git a/be/test/ai/aggregate_function_ai_agg_test.cpp 
b/be/test/ai/aggregate_function_ai_agg_test.cpp
index 4fb9c7969d0..e5cfaa889a2 100644
--- a/be/test/ai/aggregate_function_ai_agg_test.cpp
+++ b/be/test/ai/aggregate_function_ai_agg_test.cpp
@@ -20,8 +20,11 @@
 #include <gmock/gmock-matchers.h>
 #include <gtest/gtest.h>
 
+#include <cstdlib>
+#include <map>
 #include <memory>
 #include <string>
+#include <utility>
 #include <vector>
 
 #include "core/arena.h"
@@ -66,7 +69,7 @@ public:
         _agg_function->set_query_context(_query_ctx.get());
     }
 
-    void TearDown() override {}
+    void TearDown() override { unsetenv("AI_TEST_RESULT"); }
 
 protected:
     std::unique_ptr<MockRuntimeState> _runtime_state;
@@ -516,6 +519,46 @@ TEST_F(AggregateFunctionAIAggTest, 
mock_resource_send_request_test) {
     _agg_function->destroy(place);
 }
 
+TEST_F(AggregateFunctionAIAggTest, 
openai_responses_preserves_model_output_as_one_result) {
+    TAIResource ai_resource;
+    ai_resource.provider_type = "OPENAI";
+    ai_resource.model_name = "test_model";
+    ai_resource.endpoint = "https://api.openai.com/v1/responses";;
+    _query_ctx->set_ai_resources(
+            std::map<std::string, TAIResource> {{"openai_response", 
ai_resource}});
+
+    const std::vector<std::pair<std::string, std::string>> test_cases = {
+            
{R"({"object":"response","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"[\"north\",\"south\"]"}]}]})",
+             R"(["north","south"])"},
+            
{R"({"object":"response","status":"completed","output":[{"type":"message","content":[{"type":"output_text","text":"[]"}]}]})",
+             "[]"}};
+
+    for (const auto& [response, expected] : test_cases) {
+        setenv("AI_TEST_RESULT", response.c_str(), 1);
+
+        auto resource_col = ColumnString::create();
+        auto text_col = ColumnString::create();
+        auto task_col = ColumnString::create();
+        resource_col->insert_data("openai_response", 15);
+        text_col->insert_data("test input", 10);
+        task_col->insert_data("summarize", 9);
+
+        std::unique_ptr<char[]> memory(new 
char[_agg_function->size_of_data()]);
+        AggregateDataPtr place = memory.get();
+        _agg_function->create(place);
+
+        const IColumn* columns[3] = {resource_col.get(), text_col.get(), 
task_col.get()};
+        _agg_function->add(place, columns, 0, _arena);
+
+        ColumnString result_column;
+        _agg_function->insert_result_into(place, result_column);
+        StringRef result_ref = result_column.get_data_at(0);
+        EXPECT_EQ(std::string(result_ref.data, result_ref.size), expected);
+
+        _agg_function->destroy(place);
+    }
+}
+
 TEST_F(AggregateFunctionAIAggTest, missing_ai_resources_metadata_test) {
     auto empty_query_ctx = MockQueryContext::create();
     _agg_function->set_query_context(empty_query_ctx.get());
diff --git a/be/test/ai/ai_adapter_test.cpp b/be/test/ai/ai_adapter_test.cpp
index 2cfda9d7154..da40ef217dc 100644
--- a/be/test/ai/ai_adapter_test.cpp
+++ b/be/test/ai/ai_adapter_test.cpp
@@ -381,16 +381,273 @@ TEST(AI_ADAPTER_TEST, openai_adatper_responses_request) {
     ASSERT_STREQ(input[1]["content"].GetString(), inputs[0].c_str());
 }
 
-TEST(AI_ADAPTER_TEST, openai_adapter_responses_parse_response) {
+TEST(AI_ADAPTER_TEST, 
openai_adapter_responses_parse_response_skips_empty_reasoning) {
     OpenAIAdapter adapter;
-    std::string resp = R"({"output":[{"content":[{"text":"openai response 
result"}]}]})";
+    std::string resp = R"({
+        "id": "resp_123", 
+        "object": "response",
+        "status": "completed",
+        "output": [
+            {
+                "id": "rs_123",
+                "type": "reasoning",
+                "content": [],
+                "summary": []
+            },
+            {
+                "id": "msg_123",
+                "type": "message",
+                "status": "completed",
+                "role": "assistant",
+                "content": [
+                    {
+                        "type": "output_text",
+                        "text": "openai response result",
+                        "annotations": []
+                    }
+                ]
+            }
+        ]
+    })";
     std::vector<std::string> results;
     Status st = adapter.parse_response(resp, results);
-    ASSERT_TRUE(st.ok());
+    ASSERT_TRUE(st.ok()) << st.to_string();
     ASSERT_EQ(results.size(), 1);
     ASSERT_EQ(results[0], "openai response result");
 }
 
+TEST(AI_ADAPTER_TEST, 
openai_adapter_responses_parse_response_skips_reasoning_text) {
+    OpenAIAdapter adapter;
+    std::string resp = R"({
+        "id": "resp_456",
+        "object": "response",
+        "status": "completed",
+        "output": [
+            {
+                "id": "rs_456",
+                "type": "reasoning",
+                "status": "completed",
+                "content": [
+                    {
+                        "type": "reasoning_text",
+                        "text": "The model reasoning must not become a batch 
result."
+                    }
+                ],
+                "summary": []
+            },
+            {
+                "id": "msg_456",
+                "type": "message",
+                "status": "completed",
+                "role": "assistant",
+                "content": [
+                    {
+                        "type": "output_text",
+                        "text": "[\"translation one\",\"translation two\"]",
+                        "annotations": []
+                    }
+                ]
+            }
+        ]
+    })";
+    std::vector<std::string> results;
+    Status st = adapter.parse_response(resp, results);
+    ASSERT_TRUE(st.ok()) << st.to_string();
+    ASSERT_EQ(results.size(), 2);
+    EXPECT_EQ(results[0], "translation one");
+    EXPECT_EQ(results[1], "translation two");
+}
+
+TEST(AI_ADAPTER_TEST, 
openai_adapter_responses_parse_response_rejects_incomplete_status) {
+    OpenAIAdapter adapter;
+    std::string resp = R"({
+        "id": "resp_incomplete",
+        "object": "response",
+        "status": "incomplete",
+        "incomplete_details": {
+            "reason": "max_output_tokens"
+        },
+        "output": [
+            {
+                "id": "msg_incomplete",
+                "type": "message",
+                "status": "incomplete",
+                "role": "assistant",
+                "content": [
+                    {
+                        "type": "output_text",
+                        "text": "[\"truncated result\"",
+                        "annotations": []
+                    }
+                ]
+            }
+        ]
+    })";
+    std::vector<std::string> results;
+    Status st = adapter.parse_response(resp, results);
+    ASSERT_FALSE(st.ok());
+    EXPECT_THAT(st.to_string(), ::testing::HasSubstr("incomplete"));
+    EXPECT_THAT(st.to_string(), ::testing::HasSubstr("max_output_tokens"));
+    EXPECT_TRUE(results.empty());
+}
+
+TEST(AI_ADAPTER_TEST, 
openai_adapter_responses_status_cannot_fall_back_to_choices) {
+    OpenAIAdapter adapter;
+    std::string resp = R"({
+        "object": "response",
+        "status": "incomplete",
+        "incomplete_details": {
+            "reason": "max_output_tokens"
+        },
+        "output": null,
+        "choices": [
+            {
+                "message": {
+                    "content": "[\"partial result\"]"
+                }
+            }
+        ]
+    })";
+    std::vector<std::string> results;
+    Status st = adapter.parse_response(resp, results);
+    ASSERT_FALSE(st.ok());
+    EXPECT_THAT(st.to_string(), ::testing::HasSubstr("incomplete"));
+    EXPECT_THAT(st.to_string(), ::testing::HasSubstr("max_output_tokens"));
+    EXPECT_TRUE(results.empty());
+}
+
+TEST(AI_ADAPTER_TEST, 
openai_adapter_responses_parse_response_rejects_missing_final_text) {
+    OpenAIAdapter adapter;
+    std::string resp = R"({
+        "id": "resp_no_text",
+        "object": "response",
+        "status": "completed",
+        "output": [
+            {
+                "id": "rs_no_text",
+                "type": "reasoning",
+                "status": "completed",
+                "content": [],
+                "summary": []
+            }
+        ]
+    })";
+    std::vector<std::string> results;
+    Status st = adapter.parse_response(resp, results);
+    ASSERT_FALSE(st.ok());
+    EXPECT_TRUE(results.empty());
+}
+
+TEST(AI_ADAPTER_TEST, 
openai_adapter_responses_parse_response_joins_output_text_before_parsing) {
+    OpenAIAdapter adapter;
+    std::string resp = R"({
+        "id": "resp_split_text",
+        "object": "response",
+        "status": "completed",
+        "output": [
+            {
+                "id": "msg_split_text_1",
+                "type": "message",
+                "status": "completed",
+                "role": "assistant",
+                "content": [
+                    {
+                        "type": "output_text",
+                        "text": "[\"translation",
+                        "annotations": []
+                    },
+                    {
+                        "type": "output_text",
+                        "text": " one\",",
+                        "annotations": []
+                    }
+                ]
+            },
+            {
+                "id": "msg_split_text_2",
+                "type": "message",
+                "status": "completed",
+                "role": "assistant",
+                "content": [
+                    {
+                        "type": "output_text",
+                        "text": "\"translation two\"]",
+                        "annotations": []
+                    }
+                ]
+            }
+        ]
+    })";
+    std::vector<std::string> results;
+    Status st = adapter.parse_response(resp, results);
+    ASSERT_TRUE(st.ok()) << st.to_string();
+    ASSERT_EQ(results.size(), 2);
+    EXPECT_EQ(results[0], "translation one");
+    EXPECT_EQ(results[1], "translation two");
+}
+
+TEST(AI_ADAPTER_TEST, 
openai_adapter_responses_parse_response_keeps_json_array_in_opaque_mode) {
+    OpenAIAdapter adapter;
+    std::string resp = R"({
+        "id": "resp_opaque",
+        "object": "response",
+        "status": "completed",
+        "output": [
+            {
+                "id": "msg_opaque",
+                "type": "message",
+                "status": "completed",
+                "role": "assistant",
+                "content": [
+                    {
+                        "type": "output_text",
+                        "text": "[\"north\",",
+                        "annotations": []
+                    },
+                    {
+                        "type": "output_text",
+                        "text": "\"south\"]",
+                        "annotations": []
+                    }
+                ]
+            }
+        ]
+    })";
+    std::vector<std::string> results;
+    Status st = adapter.parse_response(resp, results, false);
+    ASSERT_TRUE(st.ok()) << st.to_string();
+    ASSERT_EQ(results.size(), 1);
+    EXPECT_EQ(results[0], R"(["north","south"])");
+}
+
+TEST(AI_ADAPTER_TEST, non_openai_text_adapters_keep_json_array_in_opaque_mode) 
{
+    auto check_opaque_mode = [](const char* provider, AIAdapter& adapter,
+                                const std::string& response) {
+        SCOPED_TRACE(provider);
+        std::vector<std::string> results;
+        Status st = adapter.parse_response(response, results, false);
+        ASSERT_TRUE(st.ok()) << st.to_string();
+        ASSERT_EQ(results.size(), 1);
+        EXPECT_EQ(results[0], R"(["north","south"])");
+    };
+
+    LocalAdapter local_adapter;
+    check_opaque_mode("local", local_adapter,
+                      
R"({"choices":[{"message":{"content":"[\"north\",\"south\"]"}}]})");
+
+    GeminiAdapter gemini_adapter;
+    check_opaque_mode(
+            "gemini", gemini_adapter,
+            
R"({"candidates":[{"content":{"parts":[{"text":"[\"north\",\"south\"]"}]}}]})");
+
+    AnthropicAdapter anthropic_adapter;
+    check_opaque_mode("anthropic", anthropic_adapter,
+                      
R"({"content":[{"type":"text","text":"[\"north\",\"south\"]"}]})");
+
+    MockAdapter mock_adapter;
+    check_opaque_mode("mock", mock_adapter, R"(["north","south"])");
+}
+
 TEST(AI_ADAPTER_TEST, openai_adapter_parse_response_keeps_mask_literals) {
     OpenAIAdapter adapter;
     std::string resp = R"({"choices":[{"message":{"content":"[MSKED]"}}]})";


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to