commit 013bbef42945efe028e39007e2c4b85909c42092 from: Isaac Meerleo date: Tue Jul 21 15:10:03 2026 UTC fix Codex Responses stream compatibility commit - 00df5eedf8e92b78e7db6d28f85cce5988525b62 commit + 013bbef42945efe028e39007e2c4b85909c42092 blob - b5023a2431dac3c9ef2c8c84710e5f84baf8e910 blob + e7bd92cb920dd68546ea6a1d8deac22b2c3532b0 --- docs/adr/0010-experimental-codex-subscription-compatibility.md +++ docs/adr/0010-experimental-codex-subscription-compatibility.md @@ -96,6 +96,15 @@ fixtures, all hermetic and hardened gates, and an expl check. HTTP SSE is the only v1 transport; a WebSocket requirement is a new ADR/ticket, not an adaptive fallback. +The 2026-07-21 compatibility correction keeps the original profile identity: +the pinned Codex source already modeled optional `commentary` / `final_answer` +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. + 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 `read` Tool continuation, continuity capture, honest identity, and absence of blob - c22dd7c09bb3884d673c9a8d485196b39b36e143 blob + ca2572930a445a2b62273b0b075053e007fd8864 --- docs/design/codex-subscription.md +++ docs/design/codex-subscription.md @@ -343,8 +343,10 @@ The hostile path is explicit: `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. Duplicate - or conflicting copies fail. + 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 @@ -391,6 +393,13 @@ the seam. The decoder must: - accept output-text deltas with explicit lengths; +- accept absent or exact `commentary` / `final_answer` message phases while + preserving Fugu's existing canonical text ordering; +- accept only array-shaped optional `annotations` and `logprobs` metadata on + output-text parts; neither metadata field crosses the canonical Provider + seam; +- require a text delta's optional bounded `item_id`, when present, to identify + the same message as its `output_index`; - correlate function calls by bounded output index and call identifier; - buffer fragmented function names and argument JSON under the existing per-call and aggregate Tool caps; blob - 96efc6a322c04ca4a0d98b6178ca4dba1959c005 blob + b6688a8e29c683b247344bf718b995c3912bd5cb --- regress/codex_subscription/codex_subscription_test.c +++ regress/codex_subscription/codex_subscription_test.c @@ -582,7 +582,8 @@ static const char complete_stream[] = "event: response.output_item.added\n" "data: {\"type\":\"response.output_item.added\",\"output_index\":1," "\"item\":{\"type\":\"message\",\"id\":\"msg_1\",\"role\":" - "\"assistant\",\"content\":[],\"status\":\"in_progress\"}}\n\n" + "\"assistant\",\"content\":[],\"status\":\"in_progress\",\"phase\":" + "\"commentary\"}}\n\n" "event: response.output_text.delta\n" "data: {\"type\":\"response.output_text.delta\",\"output_index\":1," "\"content_index\":0,\"delta\":\"hello\"}\n\n" @@ -590,7 +591,7 @@ static const char complete_stream[] = "data: {\"type\":\"response.output_item.done\",\"output_index\":1," "\"item\":{\"type\":\"message\",\"id\":\"msg_1\",\"role\":" "\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":" - "\"hello\"}],\"status\":\"completed\"}}\n\n" + "\"hello\"}],\"status\":\"completed\",\"phase\":\"commentary\"}}\n\n" "event: response.output_item.added\n" "data: {\"type\":\"response.output_item.added\",\"output_index\":2," "\"item\":{\"type\":\"function_call\",\"id\":\"fc_1\",\"name\":" @@ -617,7 +618,8 @@ static const char complete_stream[] = "\"rs_1\",\"summary\":[],\"encrypted_content\":\"cipher\",\"status\":" "\"completed\"},{\"type\":\"message\",\"id\":\"msg_1\",\"role\":" "\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":" - "\"hello\"}],\"status\":\"completed\"},{\"type\":\"function_call\"," + "\"hello\"}],\"status\":\"completed\",\"phase\":\"commentary\"},{" + "\"type\":\"function_call\"," "\"id\":\"fc_1\",\"name\":\"read\",\"arguments\":" "\"{\\\"path\\\":\\\"a\\\"}\",\"call_id\":\"call_1\",\"status\":" "\"completed\"}],\"usage\":{\"input_tokens\":100," @@ -706,6 +708,71 @@ test_complete_stream_and_every_split(void) capture_free(&capture); } +/* Sanitized ChatGPT Codex SSE shape observed on 2026-07-21. The terminal + * response carries usage but leaves output empty; output_item.done is the + * authoritative completed item. */ +static const char live_message_stream[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"sequence_number\":0," + "\"response\":{\"id\":\"resp_live\"}}\n\n" + "event: response.in_progress\n" + "data: {\"type\":\"response.in_progress\",\"sequence_number\":1," + "\"response\":{\"id\":\"resp_live\",\"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\":\"message\",\"id\":" + "\"msg_live\",\"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\":0," + "\"item_id\":\"msg_live\",\"content_index\":0,\"sequence_number\":3," + "\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":" + "[],\"text\":\"\"}}\n\n" + "event: response.output_text.delta\n" + "data: {\"type\":\"response.output_text.delta\",\"output_index\":0," + "\"item_id\":\"msg_live\",\"content_index\":0,\"sequence_number\":4," + "\"delta\":\"hello\",\"logprobs\":[],\"obfuscation\":\"noise\"}\n\n" + "event: response.output_text.done\n" + "data: {\"type\":\"response.output_text.done\",\"output_index\":0," + "\"item_id\":\"msg_live\",\"content_index\":0,\"sequence_number\":5," + "\"text\":\"hello\",\"logprobs\":[]}\n\n" + "event: response.content_part.done\n" + "data: {\"type\":\"response.content_part.done\",\"output_index\":0," + "\"item_id\":\"msg_live\",\"content_index\":0,\"sequence_number\":6," + "\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":" + "[],\"text\":\"hello\"}}\n\n" + "event: response.output_item.done\n" + "data: {\"type\":\"response.output_item.done\",\"output_index\":0," + "\"sequence_number\":7,\"item\":{\"type\":\"message\",\"id\":" + "\"msg_live\",\"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\":8," + "\"response\":{\"id\":\"resp_live\",\"status\":\"completed\"," + "\"output\":[],\"usage\":{\"input_tokens\":12,\"output_tokens\":3," + "\"total_tokens\":15}}}\n\n"; + +static void +test_live_message_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_message_stream, sizeof(live_message_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.nstate == 0 && capture.ndone == 1 && capture.nerror == 0 && + strcmp(capture.stop, "stop") == 0); + 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" @@ -748,13 +815,7 @@ static const char call_only_stream[] = "\"call_id\":\"call_codex_1\",\"status\":\"completed\"}}\n\n" "event: response.completed\n" "data: {\"type\":\"response.completed\",\"response\":{\"id\":" - "\"resp_codex_1\",\"status\":\"completed\",\"output\":[{\"type\":" - "\"reasoning\",\"id\":\"rs_codex_1\",\"summary\":[]," - "\"encrypted_content\":\"cipher_1\",\"status\":\"completed\"},{" - "\"type\":\"function_call\",\"id\":\"fc_codex_1\",\"name\":" - "\"shell\",\"arguments\":" - "\"{\\\"command\\\":\\\"printf CODEX-TOOL-RESULT\\\"}\"," - "\"call_id\":\"call_codex_1\",\"status\":\"completed\"}],\"usage\":{" + "\"resp_codex_1\",\"status\":\"completed\",\"output\":[],\"usage\":{" "\"input_tokens\":100,\"input_tokens_details\":{\"cached_tokens\":40," "\"cache_write_tokens\":5},\"output_tokens\":10," "\"total_tokens\":110}}}\n\n"; @@ -805,6 +866,25 @@ decode_rejected(const char *fixture) return (decode_bytes_rejected(fixture, strlen(fixture))); } +static int +decode_error_is(const char *fixture, const char *want) +{ + struct codex_sub_stream *stream; + struct capture capture; + const char *error; + int matched; + + capture_init(&capture); + stream = codex_sub_stream_new("gpt-5.5", capture_event, &capture); + matched = stream != NULL && codex_sub_stream_feed(stream, fixture, + strlen(fixture)) == -1 && + (error = codex_sub_stream_error(stream)) != NULL && + strcmp(error, want) == 0 && capture.ndone == 0; + codex_sub_stream_free(stream); + capture_free(&capture); + return (matched); +} + static void test_malformed_event_bytes(void) { @@ -1152,6 +1232,103 @@ test_sequence_conflicts_and_index_bounds(void) "data: {\"type\":\"response.incomplete\",\"response\":{\"id\":" "\"resp_x\",\"status\":\"completed\",\"incomplete_details\":{" "\"reason\":\"unknown\"}}}\n\n"; + static const char unknown_phase[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"response\":{\"id\":" + "\"resp_phase\"}}\n\n" + "event: response.output_item.added\n" + "data: {\"type\":\"response.output_item.added\",\"output_index\":0," + "\"item\":{\"type\":\"message\",\"id\":\"msg_phase\",\"role\":" + "\"assistant\",\"content\":[],\"status\":\"in_progress\",\"phase\":" + "\"analysis\"}}\n\n"; + static const char null_phase[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"response\":{\"id\":" + "\"resp_phase\"}}\n\n" + "event: response.output_item.added\n" + "data: {\"type\":\"response.output_item.added\",\"output_index\":0," + "\"item\":{\"type\":\"message\",\"id\":\"msg_phase\",\"role\":" + "\"assistant\",\"content\":[],\"status\":\"in_progress\",\"phase\":" + "null}}\n\n"; + static const char conflicting_phase[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"response\":{\"id\":" + "\"resp_phase\"}}\n\n" + "event: response.output_item.added\n" + "data: {\"type\":\"response.output_item.added\",\"output_index\":0," + "\"item\":{\"type\":\"message\",\"id\":\"msg_phase\",\"role\":" + "\"assistant\",\"content\":[],\"status\":\"in_progress\",\"phase\":" + "\"commentary\"}}\n\n" + "event: response.output_item.done\n" + "data: {\"type\":\"response.output_item.done\",\"output_index\":0," + "\"item\":{\"type\":\"message\",\"id\":\"msg_phase\",\"role\":" + "\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":" + "\"x\"}],\"status\":\"completed\",\"phase\":\"final_answer\"}}\n\n"; + static const char malformed_output_metadata[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"response\":{\"id\":" + "\"resp_meta\"}}\n\n" + "event: response.output_item.added\n" + "data: {\"type\":\"response.output_item.added\",\"output_index\":0," + "\"item\":{\"type\":\"message\",\"id\":\"msg_meta\",\"role\":" + "\"assistant\",\"content\":[],\"status\":\"in_progress\"}}\n\n" + "event: response.content_part.added\n" + "data: {\"type\":\"response.content_part.added\",\"output_index\":0," + "\"item_id\":\"msg_meta\",\"content_index\":0,\"part\":{\"type\":" + "\"output_text\",\"text\":\"\",\"annotations\":{},\"logprobs\":[]}}" + "\n\n"; + static const char unfinished_empty_terminal[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"response\":{\"id\":" + "\"resp_empty\"}}\n\n" + "event: response.output_item.added\n" + "data: {\"type\":\"response.output_item.added\",\"output_index\":0," + "\"item\":{\"type\":\"message\",\"id\":\"msg_empty\",\"role\":" + "\"assistant\",\"content\":[],\"status\":\"in_progress\"}}\n\n" + "event: response.completed\n" + "data: {\"type\":\"response.completed\",\"response\":{\"id\":" + "\"resp_empty\",\"status\":\"completed\",\"output\":[],\"usage\":" + "null}}\n\n"; + static const char control_event_before_created[] = + "event: response.\033[31m\n" + "data: {\"type\":\"response.\\u001b[31m\"}\n\n"; + static const char mismatched_text_item_id[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"response\":{\"id\":" + "\"resp_text_id\"}}\n\n" + "event: response.output_item.added\n" + "data: {\"type\":\"response.output_item.added\",\"output_index\":0," + "\"item\":{\"type\":\"message\",\"id\":\"msg_text_a\",\"role\":" + "\"assistant\",\"content\":[],\"status\":\"in_progress\"}}\n\n" + "event: response.output_text.delta\n" + "data: {\"type\":\"response.output_text.delta\",\"output_index\":0," + "\"item_id\":\"msg_text_b\",\"content_index\":0,\"delta\":\"x\"}" + "\n\n"; + static const char duplicate_text_item_id[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"response\":{\"id\":" + "\"resp_text_id\"}}\n\n" + "event: response.output_item.added\n" + "data: {\"type\":\"response.output_item.added\",\"output_index\":0," + "\"item\":{\"type\":\"message\",\"id\":\"msg_text_a\",\"role\":" + "\"assistant\",\"content\":[],\"status\":\"in_progress\"}}\n\n" + "event: response.output_text.delta\n" + "data: {\"type\":\"response.output_text.delta\",\"output_index\":0," + "\"item_id\":\"msg_text_a\",\"item_id\":\"msg_text_a\"," + "\"content_index\":0,\"delta\":\"x\"}\n\n"; + static const char noncontiguous_empty_terminal[] = + "event: response.created\n" + "data: {\"type\":\"response.created\",\"response\":{\"id\":" + "\"resp_gap\"}}\n\n" + "event: response.output_item.done\n" + "data: {\"type\":\"response.output_item.done\",\"output_index\":1," + "\"item\":{\"type\":\"message\",\"id\":\"msg_gap\",\"role\":" + "\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":" + "\"x\"}],\"status\":\"completed\"}}\n\n" + "event: response.completed\n" + "data: {\"type\":\"response.completed\",\"response\":{\"id\":" + "\"resp_gap\",\"status\":\"completed\",\"output\":[],\"usage\":" + "null}}\n\n"; struct codex_sub_stream *stream; struct capture capture; @@ -1176,6 +1353,17 @@ test_sequence_conflicts_and_index_bounds(void) CHECK(decode_rejected(duplicate_in_progress)); CHECK(decode_rejected(mismatched_failed)); CHECK(decode_rejected(bad_incomplete_status)); + CHECK(decode_error_is(unknown_phase, + "invalid Responses event sequence at response.output_item.added")); + CHECK(decode_rejected(null_phase)); + CHECK(decode_rejected(conflicting_phase)); + CHECK(decode_rejected(malformed_output_metadata)); + CHECK(decode_rejected(unfinished_empty_terminal)); + CHECK(decode_error_is(control_event_before_created, + "invalid Responses event sequence")); + CHECK(decode_rejected(mismatched_text_item_id)); + CHECK(decode_rejected(duplicate_text_item_id)); + CHECK(decode_rejected(noncontiguous_empty_terminal)); } static void @@ -1637,6 +1825,7 @@ main(void) test_reasoning_state_requires_completed_status(); test_empty_instructions_and_partial_state_preserve_call_order(); test_complete_stream_and_every_split(); + test_live_message_stream_shape(); test_call_only_stream(); test_malformed_event_bytes(); test_terminal_only_and_terminal_guards(); blob - 1a9db99a7700f48fd853e43ed61bb013b7f5f090 blob + 9555c42cb873660d6bb9b0bb0cec02d3d7016a47 --- src/common/codex_sub_stream.c +++ src/common/codex_sub_stream.c @@ -42,6 +42,12 @@ enum output_kind { OUTPUT_CALL }; +enum message_phase { + MESSAGE_PHASE_NONE, + MESSAGE_PHASE_COMMENTARY, + MESSAGE_PHASE_FINAL_ANSWER +}; + struct output_item { int used; int index; @@ -62,6 +68,7 @@ struct output_item { int content_done; int text_done; int done; + enum message_phase phase; }; struct codex_sub_stream { @@ -92,6 +99,25 @@ stream_fail(struct codex_sub_stream *stream, const cha } } +static void +stream_fail_event(struct codex_sub_stream *stream, const char *type) +{ + const unsigned char *p; + + for (p = (const unsigned char *)type; *p != '\0'; p++) + if (!((*p >= 'a' && *p <= 'z') || (*p >= '0' && *p <= '9') || + *p == '.' || *p == '_')) { + stream_fail(stream, "invalid Responses event sequence"); + return; + } + if (!stream->broken) { + stream->broken = 1; + strlcpy(stream->err, "invalid Responses event sequence at ", + sizeof(stream->err)); + strlcat(stream->err, type, sizeof(stream->err)); + } +} + static int json_is_null_value(const struct json *json, int token) { @@ -401,19 +427,41 @@ parse_call(const struct json *json, int object, const } static int +parse_message_phase(const struct json *json, int token, + enum message_phase *phase) +{ + char value[32]; + + *phase = MESSAGE_PHASE_NONE; + if (token == -1) + return (0); + if (token < 0 || copy_ident(json, token, value, sizeof(value)) == -1) + return (-1); + if (strcmp(value, "commentary") == 0) + *phase = MESSAGE_PHASE_COMMENTARY; + else if (strcmp(value, "final_answer") == 0) + *phase = MESSAGE_PHASE_FINAL_ANSWER; + else + return (-1); + return (0); +} + +static int parse_message_text(const struct json *json, int object, const char *status, - char *id, size_t idsz, struct buf *text) + char *id, size_t idsz, struct buf *text, enum message_phase *phase) { static const char *const names[] = { - "type", "id", "role", "content", "status" + "type", "id", "role", "content", "status", "phase" }; - static const char *const content_names[] = { "type", "text" }; - int fields[5], content_fields[2], i, n, part; - char type[32], role[32], content_type[32], *value; + static const char *const content_names[] = { + "type", "text", "annotations", "logprobs" + }; + int fields[6], content_fields[4], i, n, part; + char type[32], role[32], content_type[32], *value = NULL; size_t len; buf_init(text); - if (strict_fields(json, object, names, 5, fields) == -1 || + if (strict_fields(json, object, names, 6, fields) == -1 || copy_ident(json, fields[0], type, sizeof(type)) == -1 || strcmp(type, "message") != 0 || copy_ident(json, fields[1], id, idsz) == -1 || @@ -421,20 +469,28 @@ parse_message_text(const struct json *json, int object copy_ident(json, fields[2], role, sizeof(role)) == -1 || strcmp(role, "assistant") != 0 || fields[3] == -1 || !json_is_array(json, fields[3]) || fields[4] == -1 || - parse_status(json, fields[4], status) == -1) + parse_status(json, fields[4], status) == -1 || + parse_message_phase(json, fields[5], phase) == -1) goto fail; n = json_arr_len(json, fields[3]); if (n < 0) goto fail; for (i = 0; i < n; i++) { part = json_arr_get(json, fields[3], i); - if (strict_fields(json, part, content_names, 2, + value = NULL; + if (strict_fields(json, part, content_names, 4, content_fields) == -1 || copy_ident(json, content_fields[0], content_type, sizeof(content_type)) == -1 || strcmp(content_type, "output_text") != 0 || - (value = get_string(json, content_fields[1], &len, 1)) == NULL) + (value = get_string(json, content_fields[1], &len, 1)) == NULL || + (content_fields[2] != -1 && + !json_is_array(json, content_fields[2])) || + (content_fields[3] != -1 && + !json_is_array(json, content_fields[3]))) { + free(value); goto fail; + } if (len > FUGU_RESPONSE_MAX - text->len) { free(value); goto fail; @@ -543,12 +599,18 @@ reasoning_apply(struct codex_sub_stream *stream, struc static int message_apply(struct codex_sub_stream *stream, struct output_item *item, - const char *id, const struct buf *text, int terminal_copy) + const char *id, const struct buf *text, enum message_phase phase, + int terminal_copy) { struct astream_event event; if (item_set_id(stream, item, id, "msg_") == -1) return (-1); + if (item->phase != MESSAGE_PHASE_NONE && phase != MESSAGE_PHASE_NONE && + item->phase != phase) + return (-1); + if (phase != MESSAGE_PHASE_NONE) + item->phase = phase; if (item->done && !terminal_copy) return (-1); if (item->content_added && (!item->text_done || !item->content_done)) @@ -580,6 +642,7 @@ handle_final_item(struct codex_sub_stream *stream, con struct buf text, reasoning; char type[32], id[CODEX_SUB_ITEM_ID_MAX + 1]; enum output_kind kind; + enum message_phase phase; int result; if (item_type(json, object, type, sizeof(type)) == -1) @@ -613,9 +676,9 @@ handle_final_item(struct codex_sub_stream *stream, con return (result); } if (parse_message_text(json, object, "completed", id, sizeof(id), - &text) == -1) + &text, &phase) == -1) return (-1); - result = message_apply(stream, item, id, &text, terminal_copy); + result = message_apply(stream, item, id, &text, phase, terminal_copy); buf_free(&text); return (result); } @@ -626,10 +689,11 @@ handle_added(struct codex_sub_stream *stream, const st { struct output_item *item; struct parsed_call call; - int index, object, fields[5]; + int index, object, fields[6]; char type[32], id[CODEX_SUB_ITEM_ID_MAX + 1], role[32]; + enum message_phase phase; static const char *const message_names[] = { - "type", "id", "role", "content", "status" + "type", "id", "role", "content", "status", "phase" }; static const char *const reasoning_names[] = { "type", "id", "summary", "content", "encrypted_content", "status" @@ -661,17 +725,19 @@ handle_added(struct codex_sub_stream *stream, const st return (0); } if (strcmp(type, "message") == 0) { - if (strict_fields(json, object, message_names, 5, fields) == -1 || + if (strict_fields(json, object, message_names, 6, fields) == -1 || copy_ident(json, fields[1], id, sizeof(id)) == -1 || strncmp(id, "msg_", 4) != 0 || copy_ident(json, fields[2], role, sizeof(role)) == -1 || strcmp(role, "assistant") != 0 || fields[3] == -1 || !json_is_array(json, fields[3]) || fields[4] == -1 || parse_status(json, fields[4], "in_progress") == -1 || + parse_message_phase(json, fields[5], &phase) == -1 || (item = item_new(stream, index, OUTPUT_MESSAGE)) == NULL || item_set_id(stream, item, id, "msg_") == -1) return (-1); item->added = 1; + item->phase = phase; return (0); } if (strcmp(type, "reasoning") == 0) { @@ -693,14 +759,17 @@ handle_text_delta(struct codex_sub_stream *stream, con { struct output_item *item; struct astream_event event; - char *delta; + char *delta, item_id[CODEX_SUB_ITEM_ID_MAX + 1]; size_t len; - int index, token; + int index, item_token, token; int64_t content_index; if (get_index(json, root, &index) == -1 || (item = item_find(stream, index)) == NULL || item->kind != OUTPUT_MESSAGE || item->done || item->text_done || + (item_token = obj_unique(json, root, "item_id")) == -2 || + (item_token >= 0 && (copy_ident(json, item_token, item_id, + sizeof(item_id)) == -1 || strcmp(item_id, item->id) != 0)) || (token = obj_unique(json, root, "content_index")) < 0 || json_get_num(json, token, &content_index) == -1 || content_index != 0 || (token = obj_unique(json, root, "delta")) < 0 || @@ -912,8 +981,10 @@ handle_completed(struct codex_sub_stream *stream, cons if (handle_final_item(stream, json, item, i, 1) == -1) return (-1); } - if (stream->nitems != n) + if (n != 0 && stream->nitems != n) return (-1); + if (n == 0) + n = stream->nitems; for (i = 0; i < n; i++) { struct output_item *out = item_find(stream, i); @@ -1129,17 +1200,23 @@ informational_item(struct codex_sub_stream *stream, co static int parse_output_part(const struct json *json, int part, struct buf *text) { - static const char *const names[] = { "type", "text" }; - int fields[2]; - char type[32], *value; + static const char *const names[] = { + "type", "text", "annotations", "logprobs" + }; + int fields[4]; + char type[32], *value = NULL; size_t len; buf_init(text); - if (strict_fields(json, part, names, 2, fields) == -1 || + if (strict_fields(json, part, names, 4, fields) == -1 || copy_ident(json, fields[0], type, sizeof(type)) == -1 || strcmp(type, "output_text") != 0 || - (value = get_string(json, fields[1], &len, 1)) == NULL) + (value = get_string(json, fields[1], &len, 1)) == NULL || + (fields[2] != -1 && !json_is_array(json, fields[2])) || + (fields[3] != -1 && !json_is_array(json, fields[3]))) { + free(value); return (-1); + } if (len > FUGU_RESPONSE_MAX) { free(value); return (-1); @@ -1346,7 +1423,7 @@ handle_json_event(struct codex_sub_stream *stream, con else if ((result = handle_informational(stream, &json, root, type)) == -2) stream_fail(stream, "unknown Responses event type"); if (result == -1 && !stream->broken) - stream_fail(stream, "invalid Responses event sequence"); + stream_fail_event(stream, type); done: json_done(&json);