commit - d140bf3b9088768c6532da38f50345ebb99d2378
commit + 6c01aa8ef14a038f0203c91d985154e6a7c0de2e
blob - e7bd92cb920dd68546ea6a1d8deac22b2c3532b0
blob + da3fdea0e9b780c913bad0a001b23cd333553120
--- docs/adr/0010-experimental-codex-subscription-compatibility.md
+++ docs/adr/0010-experimental-codex-subscription-compatibility.md
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
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
- 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
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
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"
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[] =
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
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);
}
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)
{
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)