commit 6c01aa8ef14a038f0203c91d985154e6a7c0de2e from: Isaac Meerleo date: Tue Jul 21 17:32:28 2026 UTC Fix Codex Responses reasoning metadata parsing commit - d140bf3b9088768c6532da38f50345ebb99d2378 commit + 6c01aa8ef14a038f0203c91d985154e6a7c0de2e blob - e7bd92cb920dd68546ea6a1d8deac22b2c3532b0 blob + da3fdea0e9b780c913bad0a001b23cd333553120 --- docs/adr/0010-experimental-codex-subscription-compatibility.md +++ docs/adr/0010-experimental-codex-subscription-compatibility.md @@ -101,9 +101,14 @@ the pinned Codex source already modeled optional `comm message phases and treated `response.completed` as terminal metadata rather than a required second copy of output items. Fugu now accepts those exact phases, array-shaped output-text `annotations` / `logprobs`, and an empty -terminal output array only after all streamed items are complete. These are -decoder corrections within the pinned event vocabulary, not a new request, -route, model, or transport profile. +terminal output array only after all streamed items are complete. The same +pinned schema gives reasoning completion through the enclosing final-item event +rather than an item `status`, and classifies `response.metadata` as advisory. +Fugu therefore accepts absent status on final reasoning items, normalizes its +retained copy to `status:"completed"`, and validates then discards only the +exact response-bound HTTP metadata envelope. Metadata never becomes text, Tool +authority, usage, or Provider state. These are decoder corrections within the +pinned event vocabulary, not a new request, route, model, or transport profile. The opt-in live gate never runs under `make check` and never asks a developer or agent to copy a credential. It proves one text response, one harmless blob - ca2572930a445a2b62273b0b075053e007fd8864 blob + 3afdcaa1250a614764bb45f2620f8e0a3d647d40 --- docs/design/codex-subscription.md +++ docs/design/codex-subscription.md @@ -340,13 +340,15 @@ The hostile path is explicit: 1. `codex_sub_stream` accepts only final reasoning and function-call metadata; partial items are never retained. A reasoning item requires nonempty - `encrypted_content`; a call record requires bounded, distinct `call_id` and - provider item id. It normally takes `response.output_item.done`. If - `response.completed.output` repeats an item, identity and content must agree; - if terminal output is the only final copy, it supplies the item. A terminal - empty output array is accepted only after every contiguous streamed item is - already complete. Duplicate, unfinished, missing, or conflicting copies - fail. + `encrypted_content`; its enclosing final-item event supplies completion, so + `status` may be absent but, when present, must be exactly `completed`. The + retained canonical copy always contains `status:"completed"`. A call record + requires bounded, distinct `call_id` and provider item id. It normally takes + `response.output_item.done`. If `response.completed.output` repeats an item, + identity and canonical content must agree; if terminal output is the only + final copy, it supplies the item. A terminal empty output array is accepted + only after every contiguous streamed item is already complete. Duplicate, + unfinished, missing, or conflicting copies fail. 2. It emits `ASTREAM_PROVIDER_STATE` records to `fugu-api`. The Custodian validates each complete record, applies credential-reflection filtering, and builds one deterministic continuity object under a 2 MiB @@ -416,6 +418,12 @@ The decoder must: - retain only completed reasoning items and function-call identity records, require nonempty encrypted content and distinct bounded ids, and preserve their response order in one bounded continuity object; +- accept `response.metadata` only as the exact observed HTTP SSE envelope: + matching bounded `response_id`, nonnegative `INT_MAX`-bounded integer + `sequence_number`, and a nonempty object-valued `metadata`, with no duplicate + or additional root fields. Treat the nested object as opaque advisory data, + discard it inside `fugu-api`, and emit no text, Tool, usage, or provider-state + event from it; - ignore only well-formed informational events explicitly classified by the profile; unknown terminal or output-item semantics fail closed; - reject data after a terminal event, duplicate terminals, conflicting item @@ -548,9 +556,11 @@ Add `regress/codex_subscription/` with fixture-driven 3. every SSE byte split, multi-event reads, interleaved function calls, fragmented names/arguments, usage, normal completion, explicit failure, incomplete response, duplicate/conflicting items, output after terminal, - completed versus partial reasoning items, multiple reasoning items, - `call_id`/item-id pairing and conflicts, unknown Tool types, malformed JSON, - embedded NUL, and every byte/count cap; + statusless final versus partial or non-completed reasoning items, canonical + completed-state normalization, multiple reasoning items, exact interleaved + `response.metadata`, malformed metadata envelopes, `call_id`/item-id pairing + and conflicts, unknown Tool types, malformed JSON, embedded NUL, and every + byte/count cap; 4. credential reflection split across adjacent text, Tool, reasoning-state, error, and HTTP-body fragments; 5. config acceptance/redaction/wiping for implicit and named `codex` routes, blob - b6688a8e29c683b247344bf718b995c3912bd5cb blob + 9495d2581e7d7a66b6af3b4d89e9b21a8f2ab6e1 --- regress/codex_subscription/codex_subscription_test.c +++ regress/codex_subscription/codex_subscription_test.c @@ -773,6 +773,154 @@ test_live_message_stream_shape(void) capture_free(&capture); } +/* Sanitized ChatGPT Codex SSE shape observed on 2026-07-21. Reasoning + * items omit status, and advisory response.metadata can arrive between the + * final text delta and its boundary events. */ +static const char live_reasoning_metadata_stream[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"sequence_number\":0," + "\"response\":{\"id\":\"resp_reasoning_meta\"}}\n\n" + "event: response.in_progress\n" + "data: {\"type\":\"response.in_progress\",\"sequence_number\":1," + "\"response\":{\"id\":\"resp_reasoning_meta\",\"status\":" + "\"in_progress\"}}\n\n" + "event: response.output_item.added\n" + "data: {\"type\":\"response.output_item.added\",\"output_index\":0," + "\"sequence_number\":2,\"item\":{\"type\":\"reasoning\",\"id\":" + "\"rs_reasoning_meta\",\"summary\":[],\"content\":[]," + "\"encrypted_content\":\"cipher_added\"}}\n\n" + "event: response.output_item.done\n" + "data: {\"type\":\"response.output_item.done\",\"output_index\":0," + "\"sequence_number\":3,\"item\":{\"type\":\"reasoning\",\"id\":" + "\"rs_reasoning_meta\",\"summary\":[],\"content\":[]," + "\"encrypted_content\":\"cipher_done\"}}\n\n" + "event: response.output_item.added\n" + "data: {\"type\":\"response.output_item.added\",\"output_index\":1," + "\"sequence_number\":4,\"item\":{\"type\":\"message\",\"id\":" + "\"msg_reasoning_meta\",\"role\":\"assistant\",\"content\":[]," + "\"status\":\"in_progress\",\"phase\":\"final_answer\"}}\n\n" + "event: response.content_part.added\n" + "data: {\"type\":\"response.content_part.added\",\"output_index\":1," + "\"item_id\":\"msg_reasoning_meta\",\"content_index\":0," + "\"sequence_number\":5,\"part\":{\"type\":\"output_text\"," + "\"annotations\":[],\"logprobs\":[],\"text\":\"\"}}\n\n" + "event: response.output_text.delta\n" + "data: {\"type\":\"response.output_text.delta\",\"output_index\":1," + "\"item_id\":\"msg_reasoning_meta\",\"content_index\":0," + "\"sequence_number\":6,\"delta\":\"hello\",\"logprobs\":[]," + "\"obfuscation\":\"noise\"}\n\n" + "event: response.metadata\n" + "data: {\"type\":\"response.metadata\",\"response_id\":" + "\"resp_reasoning_meta\",\"sequence_number\":7,\"metadata\":{" + "\"opaque\":\"scrubbed\"}}\n\n" + "event: response.output_text.done\n" + "data: {\"type\":\"response.output_text.done\",\"output_index\":1," + "\"item_id\":\"msg_reasoning_meta\",\"content_index\":0," + "\"sequence_number\":8,\"text\":\"hello\",\"logprobs\":[]}\n\n" + "event: response.content_part.done\n" + "data: {\"type\":\"response.content_part.done\",\"output_index\":1," + "\"item_id\":\"msg_reasoning_meta\",\"content_index\":0," + "\"sequence_number\":9,\"part\":{\"type\":\"output_text\"," + "\"annotations\":[],\"logprobs\":[],\"text\":\"hello\"}}\n\n" + "event: response.output_item.done\n" + "data: {\"type\":\"response.output_item.done\",\"output_index\":1," + "\"sequence_number\":10,\"item\":{\"type\":\"message\",\"id\":" + "\"msg_reasoning_meta\",\"role\":\"assistant\",\"content\":[{" + "\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[]," + "\"text\":\"hello\"}],\"status\":\"completed\",\"phase\":" + "\"final_answer\"}}\n\n" + "event: response.completed\n" + "data: {\"type\":\"response.completed\",\"sequence_number\":11," + "\"response\":{\"id\":\"resp_reasoning_meta\",\"status\":" + "\"completed\",\"output\":[],\"usage\":{\"input_tokens\":12," + "\"output_tokens\":3,\"total_tokens\":15}}}\n\n"; + +static void +check_live_reasoning_metadata_state(const struct capture *capture) +{ + static const char want[] = + "{\"reasoning\":[{\"index\":0,\"item\":{\"type\":\"reasoning\"," + "\"id\":\"rs_reasoning_meta\",\"summary\":[],\"content\":[]," + "\"encrypted_content\":\"cipher_done\",\"status\":\"completed\"}}]," + "\"calls\":[]}"; + struct provider_state *state = NULL; + + CHECK(provider_state_decode(capture->state.data, capture->state.len, + &state) == 0); + CHECK(state != NULL && state->provider_type == PROVIDER_CODEX && + strcmp(state->profile, "chatgpt-codex-2026-07-17") == 0 && + strcmp(state->model, "gpt-5.5") == 0 && + strcmp(state->kind, "responses-continuity") == 0 && + state->data_len == sizeof(want) - 1 && + memcmp(state->data, want, sizeof(want) - 1) == 0); + provider_state_free(state); +} + +static void +test_live_reasoning_metadata_stream_shape(void) +{ + struct codex_sub_stream *stream; + struct capture capture; + + capture_init(&capture); + stream = codex_sub_stream_new("gpt-5.5", capture_event, &capture); + CHECK(stream != NULL && codex_sub_stream_feed(stream, + live_reasoning_metadata_stream, + sizeof(live_reasoning_metadata_stream) - 1) == 0 && + codex_sub_stream_error(stream) == NULL && capture.text.len == 5 && + memcmp(capture.text.data, "hello", 5) == 0 && capture.nusage == 1 && + capture.input_tokens == 12 && capture.output_tokens == 3 && + capture.ncalls == 0 && capture.nstate == 1 && capture.ndone == 1 && + capture.nerror == 0 && capture.nevents == 4 && + capture.events[0] == ASTREAM_TEXT && + capture.events[1] == ASTREAM_USAGE && + capture.events[2] == ASTREAM_PROVIDER_STATE && + capture.events[3] == ASTREAM_DONE && + strcmp(capture.stop, "stop") == 0); + if (capture.nstate == 1) + check_live_reasoning_metadata_state(&capture); + codex_sub_stream_free(stream); + capture_free(&capture); +} + +static const char terminal_statusless_reasoning_stream[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"response\":{\"id\":" + "\"resp_terminal_reasoning\"}}\n\n" + "event: response.completed\n" + "data: {\"type\":\"response.completed\",\"response\":{\"id\":" + "\"resp_terminal_reasoning\",\"status\":\"completed\",\"output\":[{" + "\"type\":\"reasoning\",\"id\":\"rs_reasoning_meta\",\"summary\":[]," + "\"content\":[],\"encrypted_content\":\"cipher_done\"},{\"type\":" + "\"message\",\"id\":\"msg_terminal_reasoning\",\"role\":" + "\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":" + "\"ok\"}],\"status\":\"completed\"}],\"usage\":null}}\n\n"; + +static void +test_terminal_statusless_reasoning_shape(void) +{ + struct codex_sub_stream *stream; + struct capture capture; + + capture_init(&capture); + stream = codex_sub_stream_new("gpt-5.5", capture_event, &capture); + CHECK(stream != NULL && codex_sub_stream_feed(stream, + terminal_statusless_reasoning_stream, + sizeof(terminal_statusless_reasoning_stream) - 1) == 0 && + codex_sub_stream_error(stream) == NULL && capture.text.len == 2 && + memcmp(capture.text.data, "ok", 2) == 0 && capture.nusage == 0 && + capture.ncalls == 0 && capture.nstate == 1 && capture.ndone == 1 && + capture.nerror == 0 && capture.nevents == 3 && + capture.events[0] == ASTREAM_TEXT && + capture.events[1] == ASTREAM_PROVIDER_STATE && + capture.events[2] == ASTREAM_DONE && + strcmp(capture.stop, "stop") == 0); + if (capture.nstate == 1) + check_live_reasoning_metadata_state(&capture); + codex_sub_stream_free(stream); + capture_free(&capture); +} + static const char terminal_text_stream[] = "event: response.created\n" "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_2\"}}\n\n" @@ -885,7 +1033,134 @@ decode_error_is(const char *fixture, const char *want) return (matched); } +static int +metadata_event_error_is(const char *data, const char *want) +{ + static const char created[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"response\":{\"id\":" + "\"resp_metadata_guard\"}}\n\n" + "event: response.metadata\n" + "data: "; + struct buf fixture; + int matched; + + buf_init(&fixture); + buf_add(&fixture, created, sizeof(created) - 1); + buf_addstr(&fixture, data); + buf_addstr(&fixture, "\n\n"); + buf_addc(&fixture, '\0'); + matched = decode_error_is((const char *)fixture.data, want); + buf_free(&fixture); + return (matched); +} + +static int +reasoning_status_rejected(const char *status) +{ + static const char start[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"response\":{\"id\":" + "\"resp_reasoning_guard\"}}\n\n" + "event: response.output_item.done\n" + "data: {\"type\":\"response.output_item.done\",\"output_index\":0," + "\"item\":{\"type\":\"reasoning\",\"id\":\"rs_reasoning_guard\"," + "\"summary\":[],\"content\":[],\"encrypted_content\":\"cipher\""; + static const char end[] = "}}\n\n"; + struct buf fixture; + int rejected; + + buf_init(&fixture); + buf_add(&fixture, start, sizeof(start) - 1); + buf_addstr(&fixture, status); + buf_add(&fixture, end, sizeof(end) - 1); + buf_addc(&fixture, '\0'); + rejected = decode_rejected((const char *)fixture.data); + buf_free(&fixture); + return (rejected); +} + static void +test_reasoning_and_metadata_guards(void) +{ + static const char want[] = + "invalid Responses event sequence at response.metadata"; + + CHECK(reasoning_status_rejected(",\"status\":null")); + CHECK(reasoning_status_rejected(",\"status\":\"in_progress\"")); + CHECK(reasoning_status_rejected(",\"status\":\"unknown\"")); + CHECK(reasoning_status_rejected(",\"status\":true")); + CHECK(reasoning_status_rejected(",\"status\":\"completed\"," + "\"status\":\"completed\"")); + + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"sequence_number\":7," + "\"metadata\":{\"opaque\":\"scrubbed\"}}", want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_other\",\"sequence_number\":7,\"metadata\":{" + "\"opaque\":\"scrubbed\"}}", want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":null," + "\"sequence_number\":7,\"metadata\":{\"opaque\":\"scrubbed\"}}", + want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\\u0000suffix\",\"sequence_number\":7," + "\"metadata\":{\"opaque\":\"scrubbed\"}}", want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"response_id\":\"resp_metadata_guard\"," + "\"sequence_number\":7,\"metadata\":{\"opaque\":\"scrubbed\"}}", + want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"metadata\":{\"opaque\":\"scrubbed\"}}", + want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"sequence_number\":-1,\"metadata\":{" + "\"opaque\":\"scrubbed\"}}", want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"sequence_number\":1.5,\"metadata\":{" + "\"opaque\":\"scrubbed\"}}", want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"sequence_number\":2147483648," + "\"metadata\":{\"opaque\":\"scrubbed\"}}", want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"sequence_number\":7," + "\"sequence_number\":7,\"metadata\":{\"opaque\":\"scrubbed\"}}", + want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"sequence_number\":7}", want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"sequence_number\":7,\"metadata\":null}", + want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"sequence_number\":7,\"metadata\":[]}", + want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"sequence_number\":7,\"metadata\":{}}", + want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"sequence_number\":7,\"metadata\":{" + "\"opaque\":\"scrubbed\"},\"metadata\":{\"opaque\":" + "\"scrubbed\"}}", want)); + CHECK(metadata_event_error_is( + "{\"type\":\"response.metadata\",\"response_id\":" + "\"resp_metadata_guard\",\"sequence_number\":7,\"metadata\":{" + "\"opaque\":\"scrubbed\"},\"headers\":{}}", want)); +} + +static void test_malformed_event_bytes(void) { static const char malformed_json[] = @@ -1826,8 +2101,11 @@ main(void) test_empty_instructions_and_partial_state_preserve_call_order(); test_complete_stream_and_every_split(); test_live_message_stream_shape(); + test_live_reasoning_metadata_stream_shape(); + test_terminal_statusless_reasoning_shape(); test_call_only_stream(); test_malformed_event_bytes(); + test_reasoning_and_metadata_guards(); test_terminal_only_and_terminal_guards(); test_provider_failures(); test_malformed_terminal_semantics(); blob - 9555c42cb873660d6bb9b0bb0cec02d3d7016a47 blob + 110d1b32c843f11c6656bd7ec2e80be16bcee5cc --- src/common/codex_sub_stream.c +++ src/common/codex_sub_stream.c @@ -347,7 +347,8 @@ parse_reasoning(const struct json *json, int object, c if (fields[3] != -1 && !json_is_null_value(json, fields[3]) && !json_is_array(json, fields[3])) goto fail; - if (fields[5] == -1 || parse_status(json, fields[5], "completed") == -1) + if (fields[5] != -1 && + parse_status(json, fields[5], "completed") == -1) goto fail; json_out_init(&out, canonical); json_obj_begin(&out); @@ -1166,6 +1167,30 @@ handle_error_event(struct codex_sub_stream *stream, co } static int +handle_metadata(struct codex_sub_stream *stream, const struct json *json, + int root) +{ + static const char *const names[] = { + "type", "response_id", "sequence_number", "metadata" + }; + int fields[4]; + int64_t sequence; + char type[32], response_id[CODEX_SUB_ITEM_ID_MAX + 1]; + + if (strict_fields(json, root, names, 4, fields) == -1 || + copy_ident(json, fields[0], type, sizeof(type)) == -1 || + strcmp(type, "response.metadata") != 0 || + copy_ident(json, fields[1], response_id, sizeof(response_id)) == -1 || + strcmp(response_id, stream->response_id) != 0 || fields[2] == -1 || + json_get_num(json, fields[2], &sequence) == -1 || sequence < 0 || + sequence > INT_MAX || fields[3] == -1 || + !json_is_object(json, fields[3]) || + json_obj_len(json, fields[3]) < 1) + return (-1); + return (0); +} + +static int small_index_field(const struct json *json, int root, const char *name, int *value) { @@ -1420,6 +1445,8 @@ handle_json_event(struct codex_sub_stream *stream, con result = handle_failed(stream, &json, root); else if (strcmp(type, "response.incomplete") == 0) result = handle_incomplete(stream, &json, root); + else if (strcmp(type, "response.metadata") == 0) + result = handle_metadata(stream, &json, root); else if ((result = handle_informational(stream, &json, root, type)) == -2) stream_fail(stream, "unknown Responses event type"); if (result == -1 && !stream->broken)