diff --git a/docs/serving.md b/docs/serving.md index 8a32c57640..7f9aacf95f 100644 --- a/docs/serving.md +++ b/docs/serving.md @@ -324,6 +324,7 @@ wire response contains typed `output` Items. | `metadata` | at most 16 string pairs; keys at most 64 characters and values at most 512 | | `client_metadata` | Codex client extension; an object or `null`, accepted as opaque tracing metadata with no generation effect | | `reasoning.effort` | `none` disables thinking; `low`, `medium`, or `xhigh` selects an effort exposed by the loaded chat template; `minimal`, `high`, and `max` return `reasoning_effort_not_supported` for the registered templates | +| `reasoning.summary` | omitted, `null`, or any string; every string requests the same fixed protocol placeholder without changing model execution, and the original value is echoed in the response | | `chat_template_kwargs.preserve_thinking` | optional boolean controlling whether closed-turn reasoning remains in reconstructed prompts | | `preserve_thinking` | top-level alias for the same option; conflicting values are rejected | | `text.format` | omitted or `{"type":"text"}` only | @@ -335,7 +336,7 @@ wire response contains typed `output` Items. | `top_logprobs` | omitted or `0` | | `service_tier` | omitted, `auto`, or `default`; the response reports `default` | | `background` | omitted or `false` | -| `include` | omitted or an empty array | +| `include` | omitted, empty, or `["reasoning.encrypted_content"]`; the supported value requests the local raw-reasoning mirror described below | | `stream_options.include_obfuscation` | optional boolean; accepted as a transport hint, but this local server emits no padding | | cache and client hints | `prompt_cache_key`, `prompt_cache_options`, `prompt_cache_retention`, and explicit breakpoints follow [OpenAI prompt caching](#openai-prompt-caching); `safety_identifier` and `user` are accepted as client hints | @@ -436,10 +437,15 @@ invocation are also rejected because their semantics cannot be honored. A terminal wire response has `object: "response"`, one of `completed`, `incomplete`, or `cancelled` in `status`, and a typed `output` array. NInfer may emit: -- a `reasoning` Item containing raw `reasoning_text` and an empty summary; +- a `reasoning` item containing raw `reasoning_text`; it returns a placeholder summary if + `reasoning.summary` is requested; - an assistant `message` containing an `output_text` part; - one or more `function_call` Items. +When `include:["reasoning.encrypted_content"]` is requested, reasoning Items also +carries en `encrypted_content` equal to its raw `reasoning_text`. This field is **not** +**encrypted** and provides no confidentiality. + Ordinary model/string stops produce `completed`. Output-token or context-capacity exhaustion produces `incomplete` with `incomplete_details.reason: "max_output_tokens"`. Errors accepted after an SSE response has started produce `response.failed`; validation and preparation errors remain @@ -483,6 +489,19 @@ The normal lifecycle is: 4. matching `*.done`, `response.content_part.done`, and `response.output_item.done` events; 5. exactly one `response.completed`, `response.incomplete`, or `response.failed` terminal event. +For a reasoning Item requested with any string-valued `reasoning.summary`, its +`response.output_item.added` and `.done` payloads carry the same placeholder summary. Immediately +after the Item is added, the stream emits `response.reasoning_summary_part.added`, +`response.reasoning_summary_text.delta`, `response.reasoning_summary_text.done`, and +`response.reasoning_summary_part.done` with `summary_index:0`, then continues with the raw +`reasoning_text` content lifecycle. Omitted or `null` summary requests emit none of these summary +events and retain an empty Item `summary` array. + +For `include:["reasoning.encrypted_content"]`, the in-progress +`response.output_item.added` Item omits `encrypted_content` because the complete reasoning text is +not available yet. `response.output_item.done` and the terminal Response output contain the same +complete raw mirror. A response with no reasoning Item emits no encrypted placeholder. + Function arguments use `response.function_call_arguments.delta` and `.done`. IDs, output indices, and content indices remain stable, and concatenated deltas equal the terminal Item. Responses SSE does not emit the Chat Completions `[DONE]` sentinel. With tools enabled, ordinary answer text still @@ -547,9 +566,10 @@ curl http://127.0.0.1:8080/v1/responses/input_tokens \ ``` Unsupported Create fields include Conversations, prompt templates, context management, hosted -moderation, Structured Outputs/JSON mode, non-empty `include`, background execution, compaction, -files/audio, and OpenAI-hosted/MCP/custom tools. These are compatibility boundaries, not silently -accepted placeholders. +moderation, Structured Outputs/JSON mode, `include` values other than +`reasoning.encrypted_content`, background execution, compaction, files/audio, and +OpenAI-hosted/MCP/custom tools. Except for the two explicitly documented placeholders, +these are compatibility boundaries rather than silently accepted approximations. ## Anthropic Messages diff --git a/src/serve/openai_responses.h b/src/serve/openai_responses.h index e752612dc1..a65dcc9bba 100644 --- a/src/serve/openai_responses.h +++ b/src/serve/openai_responses.h @@ -34,6 +34,7 @@ struct OpenAIResponsesPromptRequest { std::vector input_items; std::optional instructions; std::optional previous_response_id; + std::optional reasoning_summary; }; struct OpenAIResponsesCreateRequest { @@ -46,9 +47,10 @@ struct OpenAIResponsesCreateRequest { std::unordered_map tool_identities; std::optional requested_max_output_tokens; std::optional max_tool_calls; - bool parallel_tool_calls = true; - bool store = true; - bool stream = false; + bool parallel_tool_calls = true; + bool store = true; + bool stream = false; + bool include_reasoning_encrypted_content = false; }; struct OpenAIResponsesResolvedPrompt { diff --git a/src/serve/openai_responses_request.cpp b/src/serve/openai_responses_request.cpp index f460ed921a..522c94063e 100644 --- a/src/serve/openai_responses_request.cpp +++ b/src/serve/openai_responses_request.cpp @@ -920,7 +920,13 @@ void parse_reasoning(const Json& body, OpenAIResponsesPromptRequest& out) { static const std::unordered_set allowed = {"effort", "context", "summary", "generate_summary", "mode"}; reject_nonnull_unknown_members(reasoning, allowed, "reasoning"); - for (const char* key : {"context", "summary", "generate_summary", "mode"}) { + if (reasoning.contains("summary") && !reasoning.at("summary").is_null()) { + if (!reasoning.at("summary").is_string()) { + bad_request("reasoning.summary must be a string", "reasoning"); + } + out.reasoning_summary = reasoning.at("summary").get(); + } + for (const char* key : {"context", "generate_summary", "mode"}) { if (reasoning.contains(key) && !reasoning.at(key).is_null()) { bad_request("reasoning." + std::string(key) + " changes reasoning input or output and is not supported", @@ -1192,12 +1198,16 @@ OpenAIResponsesCreateRequest parse_openai_responses_create_request(const Json& b "background_not_supported"); } } - if (body.contains("include") && !body.at("include").is_null()) { + if (body.contains("include")) { if (!body.at("include").is_array()) { bad_request("include must be an array", "include"); } - if (!body.at("include").empty()) { - bad_request("the requested additional response fields have no available response " - "representation", - "include", "include_not_supported"); + for (const Json& field : body.at("include")) { + if (!field.is_string()) { bad_request("include entries must be strings", "include"); } + const std::string value = field.get(); + if (value != "reasoning.encrypted_content") { + bad_request("additional response field '" + value + "' is not supported", "include", + "include_not_supported"); + } + out.include_reasoning_encrypted_content = true; } } if (body.contains("stream_options") && !body.at("stream_options").is_null()) { diff --git a/src/serve/openai_responses_response.cpp b/src/serve/openai_responses_response.cpp index 7bb8d1ef12..896751a55e 100644 --- a/src/serve/openai_responses_response.cpp +++ b/src/serve/openai_responses_response.cpp @@ -16,6 +16,8 @@ namespace { using Json = nlohmann::json; +constexpr char kReasoningSummaryPlaceholder[] = "Reasoning summary is not supported. (Ninfer: OpenAI Responses API)"; + std::string response_status(ninfer::FinishReason reason) { switch (reason) { case ninfer::FinishReason::OutputLimit: @@ -49,14 +51,31 @@ void add_wire_function_identity(Json& object, const OpenAIResponsesCreateRequest if (position->second.wire_namespace) { object["namespace"] = *position->second.wire_namespace; } } +// Build the display-only reasoning summary requested by the client. +Json reasoning_summary(const OpenAIResponsesCreateRequest& request) { + if (!request.prompt.reasoning_summary) { return Json::array(); } + return Json::array({Json{{"type", "summary_text"}, {"text", kReasoningSummaryPlaceholder}}}); +} + +// Mirror raw reasoning into the opaque field requested by Harness. This is deliberately not +// encryption; replayed prompt and cache semantics continue to come only from reasoning_text. +void add_reasoning_encrypted_content(Json& item, const OpenAIResponsesCreateRequest& request, + const std::string& reasoning) { + if (request.include_reasoning_encrypted_content) { + item["encrypted_content"] = reasoning; + } +} + Json response_common(const std::string& id, std::int64_t created_at, const OpenAIResponsesCreateRequest& request, const OpenAIResponsesRuntimeValues& runtime) { - const Json reasoning = {{"effort", request.prompt.generation.reasoning_effort - ? Json(requested_reasoning_effort_name( - *request.prompt.generation.reasoning_effort)) - : Json(nullptr)}, - {"summary", nullptr}}; + const Json reasoning = { + {"effort", + request.prompt.generation.reasoning_effort + ? Json(requested_reasoning_effort_name(*request.prompt.generation.reasoning_effort)) + : Json(nullptr)}, + {"summary", request.prompt.reasoning_summary ? Json(*request.prompt.reasoning_summary) + : Json(nullptr)}}; return Json{ {"id", id}, {"object", "response"}, @@ -105,13 +124,15 @@ BuiltOpenAIResponse build_response(const std::string& id, std::int64_t created_a const char* reasoning_status = (!outcome.text.empty() || !outcome.tool_calls.empty()) ? "completed" : item_status.c_str(); - built.output_items.push_back( - Json{{"id", ids.reasoning}, - {"type", "reasoning"}, - {"status", reasoning_status}, - {"summary", Json::array()}, - {"content", - Json::array({Json{{"type", "reasoning_text"}, {"text", outcome.reasoning}}})}}); + Json reasoning_item = { + {"id", ids.reasoning}, + {"type", "reasoning"}, + {"status", reasoning_status}, + {"summary", reasoning_summary(request)}, + {"content", + Json::array({Json{{"type", "reasoning_text"}, {"text", outcome.reasoning}}})}}; + add_reasoning_encrypted_content(reasoning_item, request, outcome.reasoning); + built.output_items.push_back(std::move(reasoning_item)); } if (needs_message_item(outcome, status)) { @@ -233,21 +254,49 @@ class OpenAIResponsesEventStream::Impl { std::vector ensure_reasoning() { if (reasoning_started) { return {}; } - reasoning_started = true; - ids.reasoning = new_openai_response_item_id("rs"); - reasoning_index = next_output_index++; - const Json item = {{"id", ids.reasoning}, - {"type", "reasoning"}, - {"status", "in_progress"}, - {"summary", Json::array()}, - {"content", Json::array()}}; - const Json part = {{"type", "reasoning_text"}, {"text", ""}}; - return {sse(event("response.output_item.added", - Json{{"output_index", reasoning_index}, {"item", item}})), - sse(event("response.content_part.added", Json{{"item_id", ids.reasoning}, - {"output_index", reasoning_index}, - {"content_index", 0}, - {"part", part}}))}; + reasoning_started = true; + ids.reasoning = new_openai_response_item_id("rs"); + reasoning_index = next_output_index++; + const Json summary = reasoning_summary(request); + const Json item = {{"id", ids.reasoning}, + {"type", "reasoning"}, + {"status", "in_progress"}, + {"summary", summary}, + {"content", Json::array()}}; + const Json part = {{"type", "reasoning_text"}, {"text", ""}}; + std::vector events = { + sse(event("response.output_item.added", + Json{{"output_index", reasoning_index}, {"item", item}}))}; + if (!summary.empty()) { + const Json added_summary_part = {{"type", "summary_text"}, {"text", ""}}; + const Json& done_summary_part = summary.at(0); + events.push_back(sse(event("response.reasoning_summary_part.added", + Json{{"item_id", ids.reasoning}, + {"output_index", reasoning_index}, + {"summary_index", 0}, + {"part", added_summary_part}}))); + events.push_back(sse(event("response.reasoning_summary_text.delta", + Json{{"item_id", ids.reasoning}, + {"output_index", reasoning_index}, + {"summary_index", 0}, + {"delta", kReasoningSummaryPlaceholder}}))); + events.push_back(sse(event("response.reasoning_summary_text.done", + Json{{"item_id", ids.reasoning}, + {"output_index", reasoning_index}, + {"summary_index", 0}, + {"text", kReasoningSummaryPlaceholder}}))); + events.push_back(sse(event("response.reasoning_summary_part.done", + Json{{"item_id", ids.reasoning}, + {"output_index", reasoning_index}, + {"summary_index", 0}, + {"part", done_summary_part}}))); + } + events.push_back( + sse(event("response.content_part.added", Json{{"item_id", ids.reasoning}, + {"output_index", reasoning_index}, + {"content_index", 0}, + {"part", part}}))); + return events; } std::vector close_reasoning(const std::string& final_text, @@ -256,11 +305,12 @@ class OpenAIResponsesEventStream::Impl { reasoning_done = true; reasoning_text = final_text; const Json part = {{"type", "reasoning_text"}, {"text", reasoning_text}}; - const Json item = {{"id", ids.reasoning}, - {"type", "reasoning"}, - {"status", item_status}, - {"summary", Json::array()}, - {"content", Json::array({part})}}; + Json item = {{"id", ids.reasoning}, + {"type", "reasoning"}, + {"status", item_status}, + {"summary", reasoning_summary(request)}, + {"content", Json::array({part})}}; + add_reasoning_encrypted_content(item, request, reasoning_text); return {sse(event("response.reasoning_text.done", Json{{"item_id", ids.reasoning}, {"output_index", reasoning_index}, {"content_index", 0}, diff --git a/tests/test_openai_responses.cpp b/tests/test_openai_responses.cpp index 5bfb91f865..2899287bd6 100644 --- a/tests/test_openai_responses.cpp +++ b/tests/test_openai_responses.cpp @@ -20,6 +20,9 @@ namespace { using Json = nlohmann::json; using namespace ninfer::serve; +constexpr char kReasoningSummaryPlaceholder[] = + "Reasoning summary is not supported. (Ninfer: OpenAI Responses API)"; + int check(bool condition, const std::string& message) { if (condition) { return 0; } std::cerr << "FAIL: " << message << '\n'; @@ -207,6 +210,60 @@ int test_budgets_and_nonsemantic_hints() { return failures; } +int test_reasoning_encrypted_content_request() { + const Json base = {{"model", "m"}, {"input", "hello"}}; + int failures = 0; + + Json body = base; + body["include"] = Json::array({"reasoning.encrypted_content"}); + const OpenAIResponsesCreateRequest requested = + parse_openai_responses_create_request(body, limits()); + failures += check(requested.include_reasoning_encrypted_content && + requested.prompt.input_turns.size() == 1 && + requested.prompt.input_turns[0].content[0].text == "hello", + "reasoning encrypted content is retained without changing prompt input"); + + body["include"] = Json::array(); + failures += check(!parse_openai_responses_create_request(body, limits()) + .include_reasoning_encrypted_content, + "empty include retains the omitted behavior"); + + body["include"] = Json::array({"reasoning.encrypted_content", + "reasoning.encrypted_content"}); + failures += check(parse_openai_responses_create_request(body, limits()) + .include_reasoning_encrypted_content, + "duplicate supported include entries are idempotent"); + + body["include"] = Json::array({"message.output_text.logprobs"}); + const ApiError unsupported = + api_error([&] { (void)parse_openai_responses_create_request(body, limits()); }); + failures += check(unsupported.status == 400 && unsupported.param == "include" && + unsupported.code == "include_not_supported", + "unsupported additional output fields fail precisely"); + + body["include"] = Json::array({true}); + const ApiError invalid_entry = + api_error([&] { (void)parse_openai_responses_create_request(body, limits()); }); + failures += check(invalid_entry.status == 400 && invalid_entry.param == "include" && + invalid_entry.message == "include entries must be strings", + "include rejects non-string entries"); + + body["include"] = "reasoning.encrypted_content"; + const ApiError invalid_shape = + api_error([&] { (void)parse_openai_responses_create_request(body, limits()); }); + failures += check(invalid_shape.status == 400 && invalid_shape.param == "include" && + invalid_shape.message == "include must be an array", + "include rejects a non-array value"); + + body["include"] = nullptr; + const ApiError null_shape = + api_error([&] { (void)parse_openai_responses_create_request(body, limits()); }); + failures += check(null_shape.status == 400 && null_shape.param == "include" && + null_shape.message == "include must be an array", + "include rejects null when the field is present"); + return failures; +} + int test_typed_items_and_cache_markers() { const Json body = { {"model", "m"}, @@ -387,7 +444,12 @@ bool same_assistant_turn(const ChatTurn& left, const ChatTurn& right) { int test_response_output_history_round_trip() { const OpenAIResponsesCreateRequest source = parse_openai_responses_create_request( - Json{{"model", "m"}, {"input", "Inspect both files"}, {"store", false}}, limits()); + Json{{"model", "m"}, + {"input", "Inspect both files"}, + {"include", Json::array({"reasoning.encrypted_content"})}, + {"reasoning", Json{{"effort", "low"}, {"summary", "auto"}}}, + {"store", false}}, + limits()); GenerationOutcome outcome; outcome.reasoning = "I should inspect both paths."; outcome.text = "Let me check:"; @@ -410,14 +472,26 @@ int test_response_output_history_round_trip() { } } input.push_back(Json{{"type", "message"}, {"role", "user"}, {"content", "Continue"}}); + Json plain_input = input; + for (Json& item : plain_input) { + if (item.at("type") == "reasoning") { item.erase("encrypted_content"); } + } const OpenAIResponsesCreateRequest replay = parse_openai_responses_create_request( - Json{{"model", "m"}, {"input", std::move(input)}, {"store", false}}, limits()); + Json{{"model", "m"}, + {"input", input}, + {"include", Json::array({"reasoning.encrypted_content"})}, + {"store", false}}, + limits()); + const OpenAIResponsesCreateRequest plain_replay = parse_openai_responses_create_request( + Json{{"model", "m"}, {"input", std::move(plain_input)}, {"store", false}}, limits()); int failures = 0; - failures += - check(built.output_history.size() == 1 && replay.prompt.input_turns.size() == 5 && - same_assistant_turn(replay.prompt.input_turns[1], built.output_history[0]), - "Responses output Items did not round-trip to their stored assistant history"); + failures += check( + built.output_history.size() == 1 && replay.prompt.input_turns.size() == 5 && + built.output_items[0].at("encrypted_content") == outcome.reasoning && + same_assistant_turn(replay.prompt.input_turns[1], built.output_history[0]) && + same_assistant_turn(replay.prompt.input_turns[1], plain_replay.prompt.input_turns[1]), + "Reasoning Items did not round-trip without changing prompt or cache semantics"); GenerationOutcome incomplete; incomplete.reasoning = "unfinished reasoning"; @@ -815,12 +889,13 @@ int test_previous_response_call_graph() { } int test_response_object() { - const OpenAIResponsesCreateRequest request = - parse_openai_responses_create_request(Json{{"model", "m"}, - {"input", "hello"}, - {"reasoning", Json{{"effort", "low"}}}, - {"store", false}}, - limits()); + const OpenAIResponsesCreateRequest request = parse_openai_responses_create_request( + Json{{"model", "m"}, + {"input", "hello"}, + {"include", Json::array({"reasoning.encrypted_content"})}, + {"reasoning", Json{{"effort", "low"}, {"summary", "concise"}}}, + {"store", false}}, + limits()); OpenAIResponsesRuntimeValues runtime; runtime.temperature = 0.6F; runtime.top_p = 0.95F; @@ -828,20 +903,49 @@ int test_response_object() { make_openai_response_object("resp_test", 123, request, runtime, sample_outcome()); const Json& response = built.body; int failures = 0; + const Json summary = + Json::array({Json{{"type", "summary_text"}, {"text", kReasoningSummaryPlaceholder}}}); failures += check(response.at("object") == "response" && response.at("status") == "completed" && response.at("completed_at").is_number_integer(), "completed response has a completion timestamp"); + failures += + check(response.at("reasoning").at("summary") == "concise" && + response.at("output")[0].at("summary") == summary && + response.at("output")[0].at("content")[0].at("text") == "thought" && + response.at("output")[0].at("encrypted_content") == "thought", + "reasoning placeholders preserve raw reasoning in both replays"); failures += check(response.at("max_output_tokens").is_null(), "omitted output budget remains null in the response"); failures += check(response.at("output").size() == 2 && response.at("output")[0].at("type") == "reasoning" && response.at("output")[1].at("type") == "message", "reasoning and message are emitted as typed output Items"); - failures += - check(response.at("usage").at("input_tokens_details").at("cached_tokens") == 4 && - response.at("usage").at("output_tokens_details").at("reasoning_tokens") == 3 && - response.at("usage").at("total_tokens") == 18, - "usage and cached token details serialized"); + failures += check( + response.at("usage").at("input_tokens_details").at("cached_tokens") == 4 && + response.at("usage").at("output_tokens") == 7 && + response.at("usage").at("output_tokens_details").at("reasoning_tokens") == 3 && + response.at("usage").at("total_tokens") == 18 && built.output_history.size() == 1 && + built.output_history[0].reasoning_content == "thought", + "summary placeholder does not enter usage or persisted reasoning history"); + + const OpenAIResponsesCreateRequest omitted_request = parse_openai_responses_create_request( + Json{{"model", "m"}, {"input", "hello"}, {"store", false}}, limits()); + const BuiltOpenAIResponse omitted_summary = make_openai_response_object( + "resp_omitted_summary", 123, omitted_request, runtime, sample_outcome()); + failures += check(omitted_summary.body.at("reasoning").at("summary").is_null() && + omitted_summary.body.at("output")[0].at("summary").empty() && + !omitted_summary.body.at("output")[0].contains("encrypted_content"), + "omitted reasoning output options retain the default Item shape"); + + GenerationOutcome answer_only = sample_outcome(); + answer_only.reasoning.clear(); + answer_only.reasoning_tokens = 0; + const BuiltOpenAIResponse without_reasoning = + make_openai_response_object("resp_answer_only", 123, request, runtime, answer_only); + failures += check(without_reasoning.body.at("reasoning").at("summary") == "concise" && + without_reasoning.body.at("output").size() == 1 && + without_reasoning.body.at("output")[0].at("type") == "message", + "requested summary does not invent a reasoning Item"); GenerationOutcome incomplete = sample_outcome(); incomplete.text.clear(); @@ -851,7 +955,8 @@ int test_response_object() { failures += check(limited.body.at("status") == "incomplete" && limited.body.at("completed_at").is_null() && limited.body.at("output").size() == 1 && - limited.body.at("output")[0].at("type") == "reasoning", + limited.body.at("output")[0].at("type") == "reasoning" && + limited.body.at("output")[0].at("encrypted_content") == "thought", "reasoning-only incomplete output does not invent an empty message"); GenerationOutcome tools = sample_outcome(); @@ -871,8 +976,14 @@ int test_response_object() { } int test_sse_sequence_and_failures() { - OpenAIResponsesCreateRequest request = parse_openai_responses_create_request( - Json{{"model", "m"}, {"input", "hello"}, {"stream", true}}, limits()); + OpenAIResponsesCreateRequest request = + parse_openai_responses_create_request(Json{{"model", "m"}, + {"input", "hello"}, + {"include", Json::array( + {"reasoning.encrypted_content"})}, + {"reasoning", Json{{"summary", "detailed"}}}, + {"stream", true}}, + limits()); OpenAIResponsesEventStream encoder("resp_stream", 123, request, {}); std::vector wire = encoder.start(); std::vector next = encoder.reasoning_delta("thought"); @@ -887,20 +998,115 @@ int test_sse_sequence_and_failures() { int failures = 0; std::uint64_t expected_sequence = 0; std::string text_deltas; + std::string summary_deltas; + std::string reasoning_id; + std::vector event_types; + const Json summary = + Json::array({Json{{"type", "summary_text"}, {"text", kReasoningSummaryPlaceholder}}}); for (const std::string& event : wire) { failures += check(event.find("[DONE]") == std::string::npos, "Responses stream does not use Chat [DONE]"); - const Json payload = parse_event(event); + const Json payload = parse_event(event); + const std::string type = payload.at("type").get(); + event_types.push_back(type); failures += check(payload.at("sequence_number") == expected_sequence++, "SSE sequence numbers are contiguous"); - if (payload.at("type") == "response.output_text.delta") { + if (type == "response.output_item.added" && payload.at("item").at("type") == "reasoning") { + reasoning_id = payload.at("item").at("id").get(); + failures += check(payload.at("output_index") == 0 && + payload.at("item").at("summary") == summary && + !payload.at("item").contains("encrypted_content"), + "reasoning output_item.added defers raw encrypted content"); + } else if (type == "response.output_item.done" && + payload.at("item").at("type") == "reasoning") { + failures += check(payload.at("output_index") == 0 && + payload.at("item").at("id") == reasoning_id && + payload.at("item").at("summary") == summary && + payload.at("item").at("encrypted_content") == "thought", + "reasoning output_item.done carries the complete raw mirror"); + } else if (type.starts_with("response.reasoning_summary_")) { + failures += + check(payload.at("item_id") == reasoning_id && payload.at("output_index") == 0 && + payload.at("summary_index") == 0, + "reasoning summary events retain stable Item indices"); + if (type == "response.reasoning_summary_part.added") { + failures += + check(payload.at("part") == Json{{"type", "summary_text"}, {"text", ""}}, + "reasoning summary part starts empty"); + } else if (type == "response.reasoning_summary_text.delta") { + summary_deltas += payload.at("delta").get(); + } else if (type == "response.reasoning_summary_text.done") { + failures += check(payload.at("text") == kReasoningSummaryPlaceholder, + "reasoning summary text done carries the placeholder"); + } else if (type == "response.reasoning_summary_part.done") { + failures += check(payload.at("part") == summary.at(0), + "reasoning summary part done carries the placeholder"); + } + } + if (type == "response.output_text.delta") { text_deltas += payload.at("delta").get(); } } - failures += check(parse_event(wire.front()).at("type") == "response.created" && - parse_event(wire.back()).at("type") == "response.completed" && - text_deltas == "answer", - "SSE starts, reconstructs output, and terminates canonically"); + const std::vector expected_types = {"response.created", + "response.in_progress", + "response.output_item.added", + "response.reasoning_summary_part.added", + "response.reasoning_summary_text.delta", + "response.reasoning_summary_text.done", + "response.reasoning_summary_part.done", + "response.content_part.added", + "response.reasoning_text.delta", + "response.reasoning_text.done", + "response.content_part.done", + "response.output_item.done", + "response.output_item.added", + "response.content_part.added", + "response.output_text.delta", + "response.output_text.delta", + "response.output_text.done", + "response.content_part.done", + "response.output_item.done", + "response.completed"}; + failures += + check(event_types == expected_types && summary_deltas == kReasoningSummaryPlaceholder, + "SSE emits the complete reasoning summary lifecycle in order"); + failures += check( + parse_event(wire.front()).at("type") == "response.created" && + parse_event(wire.back()).at("type") == "response.completed" && + parse_event(wire.back()).at("response").at("reasoning").at("summary") == "detailed" && + parse_event(wire.back()).at("response").at("output")[0].at("summary") == summary && + parse_event(wire.back()) + .at("response") + .at("output")[0] + .at("encrypted_content") == "thought" && + text_deltas == "answer", + "SSE starts, reconstructs output, and terminates canonically"); + + OpenAIResponsesCreateRequest no_summary_request = parse_openai_responses_create_request( + Json{{"model", "m"}, {"input", "hello"}, {"stream", true}}, limits()); + OpenAIResponsesEventStream no_summary_stream("resp_no_summary", 123, no_summary_request, {}); + std::vector no_summary_wire = no_summary_stream.start(); + next = no_summary_stream.reasoning_delta("thought"); + no_summary_wire.insert(no_summary_wire.end(), next.begin(), next.end()); + OpenAIResponsesStreamFinish no_summary_finish = no_summary_stream.finish(sample_outcome()); + no_summary_wire.insert(no_summary_wire.end(), no_summary_finish.events_before_terminal.begin(), + no_summary_finish.events_before_terminal.end()); + bool saw_summary_event = false; + bool saw_empty_summary = false; + bool saw_encrypted_content = false; + for (const std::string& event : no_summary_wire) { + const Json payload = parse_event(event); + const std::string type = payload.at("type").get(); + saw_summary_event = saw_summary_event || type.starts_with("response.reasoning_summary_"); + if ((type == "response.output_item.added" || type == "response.output_item.done") && + payload.at("item").at("type") == "reasoning") { + saw_empty_summary = payload.at("item").at("summary").empty(); + saw_encrypted_content = + saw_encrypted_content || payload.at("item").contains("encrypted_content"); + } + } + failures += check(!saw_summary_event && saw_empty_summary && !saw_encrypted_content, + "omitted reasoning options emit neither summary nor encrypted placeholders"); OpenAIResponsesEventStream failed("resp_failed", 123, std::move(request), {}); (void)failed.start(); @@ -964,6 +1170,7 @@ int main() { int failures = 0; failures += test_basic_request_and_resolution(); failures += test_budgets_and_nonsemantic_hints(); + failures += test_reasoning_encrypted_content_request(); failures += test_typed_items_and_cache_markers(); failures += test_contiguous_assistant_items(); failures += test_response_output_history_round_trip();