Skip to content

Commit 50d786c

Browse files
committed
fix(gateway): persist safe public responses stream summaries
Record bounded public Responses stream summaries through relay finalization and accounting metadata so local OpenCode /v1/responses requests can be correlated without storing raw stream content. Harden the accounting sanitizer for public_openai_responses_stream by allowlisting map fields, finite terminal labels, and malformed non-map values. Verification: mix precommit (3003 tests); mix quality; focused accounting metadata suite (8 tests); localhost:4000 OpenCode smoke evidence.
1 parent 3097f5c commit 50d786c

14 files changed

Lines changed: 1313 additions & 192 deletions

File tree

lib/codex_pooler/accounting/metadata.ex

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,30 @@ defmodule CodexPooler.Accounting.Metadata do
1414
@usage_not_applicable "not_applicable"
1515
@redacted "[REDACTED]"
1616
@sensitive_key_fragments ~w(api_key apikey authorization bearer token access_token refresh_token upstream_token upstream_secret cookie set-cookie secret password prompt messages input output completion content raw_request raw_response body payload file filename audio image transcript transcription upload_url download_url sas_url signed_url auth_json chatgpt_account_id)
17+
@public_openai_responses_stream_modes ~w(normalized passthrough)
18+
@public_openai_responses_stream_terminal_values ~w(completed failed incomplete)
19+
@public_openai_responses_stream_boolean_keys ~w(
20+
created_seen
21+
visible_seen
22+
terminal_seen
23+
synthetic_terminal_sent
24+
passthrough_seen
25+
)
26+
@public_openai_responses_stream_counter_keys ~w(
27+
delta_count
28+
delta_bytes
29+
text_done_count
30+
text_done_bytes
31+
item_done_count
32+
source_chunk_count
33+
stream_bytes
34+
relay_bytes
35+
)
36+
@public_openai_responses_stream_terminal_keys ~w(
37+
finish_class
38+
terminal_kind
39+
terminal_status
40+
)
1741
@sensitive_exact_keys MapSet.new([
1842
"analytics",
1943
"arc",
@@ -297,6 +321,9 @@ defmodule CodexPooler.Accounting.Metadata do
297321
normalized == "payload_compression" ->
298322
sanitize_payload_compression_map(value)
299323

324+
normalized == "public_openai_responses_stream" ->
325+
sanitize_public_openai_responses_stream_map(value)
326+
300327
sensitive_key?(normalized) ->
301328
@redacted
302329

@@ -311,26 +338,67 @@ defmodule CodexPooler.Accounting.Metadata do
311338
end
312339

313340
defp sanitize_value(value, key) when is_list(value) do
314-
if sensitive_key?(key) do
315-
@redacted
316-
else
317-
Enum.map(value, &sanitize_value(&1, key))
341+
cond do
342+
public_openai_responses_stream_key?(key) -> %{}
343+
sensitive_key?(key) -> @redacted
344+
true -> Enum.map(value, &sanitize_value(&1, key))
318345
end
319346
end
320347

321348
defp sanitize_value(value, key) when is_binary(value) do
322349
cond do
350+
public_openai_responses_stream_key?(key) -> %{}
323351
sensitive_key?(key) -> @redacted
324352
sensitive_binary?(value) -> @redacted
325353
true -> value
326354
end
327355
end
328356

329-
defp sanitize_value(value, _key), do: value
357+
defp sanitize_value(value, key) do
358+
if public_openai_responses_stream_key?(key), do: %{}, else: value
359+
end
330360

331361
defp sanitize_payload_compression_map(value) when is_map(value),
332362
do: RequestCompressionMetadata.sanitize_map(value)
333363

364+
defp sanitize_public_openai_responses_stream_map(value) when is_map(value) do
365+
Enum.reduce(value, %{}, fn {child_key, child_value}, summary ->
366+
key = normalize_key(child_key)
367+
368+
case sanitize_public_openai_responses_stream_field(key, child_value) do
369+
:drop -> summary
370+
sanitized_value -> Map.put(summary, key, sanitized_value)
371+
end
372+
end)
373+
end
374+
375+
defp sanitize_public_openai_responses_stream_field("schema_version", value)
376+
when is_integer(value) and value > 0,
377+
do: value
378+
379+
defp sanitize_public_openai_responses_stream_field("mode", value)
380+
when value in @public_openai_responses_stream_modes,
381+
do: value
382+
383+
defp sanitize_public_openai_responses_stream_field(key, value)
384+
when key in @public_openai_responses_stream_boolean_keys and is_boolean(value),
385+
do: value
386+
387+
defp sanitize_public_openai_responses_stream_field(key, value)
388+
when key in @public_openai_responses_stream_counter_keys and is_integer(value) and
389+
value >= 0,
390+
do: value
391+
392+
defp sanitize_public_openai_responses_stream_field(key, value)
393+
when key in @public_openai_responses_stream_terminal_keys do
394+
if value in @public_openai_responses_stream_terminal_values, do: value, else: nil
395+
end
396+
397+
defp sanitize_public_openai_responses_stream_field(_key, _value), do: :drop
398+
399+
defp public_openai_responses_stream_key?(key),
400+
do: normalize_key(key) == "public_openai_responses_stream"
401+
334402
defp sensitive_binary?(value) do
335403
String.match?(value, ~r/sk-cxp-[a-f0-9]{12}-[A-Za-z0-9_-]+/) or
336404
String.match?(value, ~r/(?i)bearer\s+[A-Za-z0-9._~+\/-]+=*/) or

lib/codex_pooler/gateway/runtime/finalization/finalization.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ defmodule CodexPooler.Gateway.Runtime.Finalization do
194194
to: Streaming,
195195
as: :finalize_success
196196

197+
@spec finalize_stream_success(binary(), ResponseContext.t(), callbacks(), term()) ::
198+
stream_finalization_result()
199+
defdelegate finalize_stream_success(body, response_context, callbacks, stream_state),
200+
to: Streaming,
201+
as: :finalize_success
202+
197203
@spec record_retryable_first_event_stream_failure(
198204
binary(),
199205
stream_failure(),
@@ -221,6 +227,12 @@ defmodule CodexPooler.Gateway.Runtime.Finalization do
221227
to: Streaming,
222228
as: :finalize_failure
223229

230+
@spec finalize_stream_failure(binary(), term(), ResponseContext.t(), term()) ::
231+
stream_finalization_result()
232+
defdelegate finalize_stream_failure(body, reason, response_context, stream_state),
233+
to: Streaming,
234+
as: :finalize_failure
235+
224236
@spec stream_error_code(term()) :: String.t()
225237
defdelegate stream_error_code(reason), to: Streaming, as: :error_code
226238

lib/codex_pooler/gateway/runtime/finalization/metadata.ex

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.Metadata do
33

44
alias CodexPooler.Gateway.Payloads.DebugPayloadSummary
55
alias CodexPooler.Gateway.Payloads.RequestOptions
6+
alias CodexPooler.Gateway.Runtime.Streaming.DownstreamStream
67
alias CodexPooler.Gateway.Transports.BoundedResponseBody
78
alias CodexPooler.Quotas.Evidence.CodexParsers.RateLimitReachedType
89

@@ -11,6 +12,27 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.Metadata do
1112
"/backend-api/codex/responses/compact"
1213
]
1314

15+
@public_openai_responses_stream_keys ~w(
16+
schema_version
17+
mode
18+
created_seen
19+
visible_seen
20+
delta_count
21+
delta_bytes
22+
text_done_count
23+
text_done_bytes
24+
item_done_count
25+
terminal_seen
26+
terminal_kind
27+
terminal_status
28+
finish_class
29+
synthetic_terminal_sent
30+
source_chunk_count
31+
stream_bytes
32+
relay_bytes
33+
passthrough_seen
34+
)
35+
1436
@spec response_metadata(Req.Response.t(), String.t() | nil, RequestOptions.t() | map()) ::
1537
map()
1638
def response_metadata(response, error_kind, opts) do
@@ -80,6 +102,13 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.Metadata do
80102
|> Map.put("stream_error_code", failure.code)
81103
end
82104

105+
@spec merge_stream_state_metadata(map(), term()) :: map()
106+
def merge_stream_state_metadata(metadata, state) when is_map(metadata) do
107+
Map.merge(metadata, public_openai_responses_stream_metadata(state))
108+
end
109+
110+
def merge_stream_state_metadata(metadata, _state), do: metadata
111+
83112
@spec maybe_put_masked_error_metadata(map(), String.t() | nil, String.t()) :: map()
84113
def maybe_put_masked_error_metadata(metadata, upstream_code, code)
85114
when is_binary(upstream_code) and upstream_code != code do
@@ -177,6 +206,19 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.Metadata do
177206

178207
defp maybe_put_websocket_frame_headers(metadata, _headers), do: metadata
179208

209+
defp public_openai_responses_stream_metadata(state) do
210+
case DownstreamStream.public_openai_responses_stream_metadata(state) do
211+
%{"public_openai_responses_stream" => summary} when is_map(summary) ->
212+
%{
213+
"public_openai_responses_stream" =>
214+
Map.take(summary, @public_openai_responses_stream_keys)
215+
}
216+
217+
_metadata ->
218+
%{}
219+
end
220+
end
221+
180222
@spec maybe_put_backend_turn_state_response_header(
181223
[{String.t(), String.t()}],
182224
Req.Response.t(),

lib/codex_pooler/gateway/runtime/finalization/streaming.ex

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,14 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.Streaming do
2828

2929
@spec finalize_success(binary(), ResponseContext.t(), callbacks()) ::
3030
finalization_result()
31-
def finalize_success(body, %ResponseContext{context: context, response: response}, callbacks) do
31+
@spec finalize_success(binary(), ResponseContext.t(), callbacks(), term()) ::
32+
finalization_result()
33+
def finalize_success(
34+
body,
35+
%ResponseContext{context: context, response: response},
36+
callbacks,
37+
stream_state \\ nil
38+
) do
3239
%{
3340
reserved: reserved,
3441
attempt: attempt,
@@ -44,7 +51,9 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.Streaming do
4451
SettlementAttrs.success(
4552
context,
4653
response.status,
47-
Metadata.response_metadata(response, nil, request_options),
54+
response
55+
|> Metadata.response_metadata(nil, request_options)
56+
|> Metadata.merge_stream_state_metadata(stream_state),
4857
started: started
4958
)
5059
) do
@@ -135,7 +144,13 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.Streaming do
135144
end
136145

137146
@spec finalize_failure(binary(), term(), ResponseContext.t()) :: finalization_result()
138-
def finalize_failure(body, reason, %ResponseContext{context: context, response: response}) do
147+
@spec finalize_failure(binary(), term(), ResponseContext.t(), term()) :: finalization_result()
148+
def finalize_failure(
149+
body,
150+
reason,
151+
%ResponseContext{context: context, response: response},
152+
stream_state \\ nil
153+
) do
139154
code = error_code(reason)
140155
terminal_failure = terminal_failure_reason(reason)
141156

@@ -144,6 +159,7 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.Streaming do
144159
attempt_metadata =
145160
response
146161
|> Metadata.response_metadata("stream_interrupted", context.request_options)
162+
|> Metadata.merge_stream_state_metadata(stream_state)
147163
|> Metadata.maybe_put_masked_error_metadata(
148164
terminal_failure && terminal_failure.upstream_code,
149165
code

0 commit comments

Comments
 (0)