Skip to content

Commit 2a22bd7

Browse files
committed
fix(gateway): track oversized terminal response SSE events
1 parent 548909e commit 2a22bd7

8 files changed

Lines changed: 490 additions & 29 deletions

File tree

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

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ defmodule CodexPooler.Gateway.Runtime.Streaming.DownstreamStream do
2929
|> Map.put(:public_openai_responses_data_seen?, false)
3030
|> Map.put(:public_openai_responses_terminal_seen?, false)
3131
|> Map.put(:public_openai_responses_response_id, nil)
32+
|> Map.put(:public_openai_responses_terminal_kind, nil)
3233

3334
true ->
3435
state
@@ -79,6 +80,13 @@ defmodule CodexPooler.Gateway.Runtime.Streaming.DownstreamStream do
7980

8081
def keepalive_allowed?(_state), do: true
8182

83+
@spec terminal_outcome(state()) :: :completed | :incomplete | :failed | nil
84+
def terminal_outcome(%{public_openai_responses_terminal_kind: kind})
85+
when kind in [:completed, :incomplete, :failed],
86+
do: kind
87+
88+
def terminal_outcome(_state), do: nil
89+
8290
@spec synthetic_terminal_failure(state(), term()) :: {binary() | nil, state()}
8391
def synthetic_terminal_failure(
8492
%{
@@ -118,13 +126,26 @@ defmodule CodexPooler.Gateway.Runtime.Streaming.DownstreamStream do
118126
state =
119127
state
120128
|> Map.put(:public_openai_responses, stream_state)
129+
|> maybe_mark_passthrough_terminal(stream_state)
121130
|> track_public_openai_responses_output(data)
122131

123132
{data, state}
124133
end
125134

126135
defp normalize_public_openai_responses_stream_data(data, state), do: {data, state}
127136

137+
defp maybe_mark_passthrough_terminal(state, stream_state) do
138+
case StreamProtocol.public_openai_responses_passthrough_terminal_kind(stream_state) do
139+
kind when kind in [:completed, :incomplete, :failed] ->
140+
state
141+
|> Map.put(:public_openai_responses_terminal_seen?, true)
142+
|> Map.put(:public_openai_responses_terminal_kind, kind)
143+
144+
_kind ->
145+
state
146+
end
147+
end
148+
128149
defp track_public_openai_responses_output(state, data) when is_binary(data) do
129150
{blocks, _buffer} = StreamProtocol.complete_sse_blocks(data, bounded?: false)
130151

@@ -134,7 +155,9 @@ defmodule CodexPooler.Gateway.Runtime.Streaming.DownstreamStream do
134155
end
135156

136157
defp track_public_openai_responses_block(state, "data: [DONE]") do
137-
%{state | public_openai_responses_terminal_seen?: true}
158+
state
159+
|> Map.put(:public_openai_responses_terminal_seen?, true)
160+
|> Map.put(:public_openai_responses_terminal_kind, :completed)
138161
end
139162

140163
defp track_public_openai_responses_block(state, block) do
@@ -249,10 +272,14 @@ defmodule CodexPooler.Gateway.Runtime.Streaming.DownstreamStream do
249272
end
250273

251274
defp maybe_mark_public_openai_responses_terminal_seen(state, event) do
252-
if StreamProtocol.terminal_outcome_event(event) do
253-
%{state | public_openai_responses_terminal_seen?: true}
254-
else
255-
state
275+
case StreamProtocol.terminal_outcome_event(event) do
276+
{:ok, %{kind: kind}} when kind in [:completed, :incomplete, :failed] ->
277+
state
278+
|> Map.put(:public_openai_responses_terminal_seen?, true)
279+
|> Map.put(:public_openai_responses_terminal_kind, kind)
280+
281+
_outcome ->
282+
state
256283
end
257284
end
258285

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

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,20 +218,39 @@ defmodule CodexPooler.Gateway.Runtime.Streaming.StreamDispatch do
218218

219219
defp http_stream_terminal_failure_writer do
220220
fn state, reason ->
221-
write_public_openai_responses_terminal_failure(state, reason)
221+
case DownstreamStream.terminal_outcome(state) do
222+
terminal when terminal in [:completed, :incomplete] ->
223+
{:success, state, ""}
224+
225+
_missing_or_failed_terminal ->
226+
write_public_openai_responses_terminal_failure(state, reason)
227+
end
222228
end
223229
end
224230

225231
defp http_stream_terminal_success_hook do
226232
fn state ->
227-
case write_public_openai_responses_terminal_failure(state, :upstream_stream_interrupted) do
228-
{:ok, state, ""} -> {:ok, state, ""}
229-
{:ok, state, data} -> {:failure, state, data, :upstream_stream_interrupted}
230-
{:error, _reason} -> {:failure, state, "", :upstream_stream_interrupted}
233+
case DownstreamStream.terminal_outcome(state) do
234+
:failed ->
235+
{:failure, state, "", :upstream_stream_interrupted}
236+
237+
terminal when terminal in [:completed, :incomplete] ->
238+
{:ok, state, ""}
239+
240+
_missing_terminal ->
241+
missing_public_openai_responses_terminal_result(state)
231242
end
232243
end
233244
end
234245

246+
defp missing_public_openai_responses_terminal_result(state) do
247+
case write_public_openai_responses_terminal_failure(state, :upstream_stream_interrupted) do
248+
{:ok, state, ""} -> {:ok, state, ""}
249+
{:ok, state, data} -> {:failure, state, data, :upstream_stream_interrupted}
250+
{:error, _reason} -> {:failure, state, "", :upstream_stream_interrupted}
251+
end
252+
end
253+
235254
defp write_public_openai_responses_terminal_failure(state, reason) do
236255
case DownstreamStream.synthetic_terminal_failure(state, reason) do
237256
{nil, state} ->

lib/codex_pooler/gateway/transports/streaming/stream_protocol.ex

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ defmodule CodexPooler.Gateway.Transports.Streaming.StreamProtocol do
6464
required(:buffer) => binary(),
6565
required(:created?) => boolean(),
6666
required(:text_delta?) => boolean(),
67-
required(:passthrough?) => boolean()
67+
required(:passthrough?) => boolean(),
68+
required(:passthrough_terminal) => PublicResponses.passthrough_terminal_state() | nil,
69+
required(:passthrough_terminal_kind) => atom() | nil,
70+
required(:passthrough_terminal_seen?) => boolean()
6871
}
6972
@type websocket_frame_headers :: %{optional(String.t()) => String.t()}
7073

@@ -122,6 +125,11 @@ defmodule CodexPooler.Gateway.Transports.Streaming.StreamProtocol do
122125
def normalize_public_openai_responses_sse_data(data, state),
123126
do: PublicResponses.normalize_data(data, state)
124127

128+
@spec public_openai_responses_passthrough_terminal_kind(public_openai_responses_stream_state()) ::
129+
atom() | nil
130+
def public_openai_responses_passthrough_terminal_kind(state),
131+
do: PublicResponses.passthrough_terminal_kind(state)
132+
125133
@spec synthetic_public_openai_responses_failure_sse(String.t() | nil, term()) :: binary()
126134
def synthetic_public_openai_responses_failure_sse(response_id, _reason) do
127135
error = %{
@@ -399,14 +407,22 @@ defmodule CodexPooler.Gateway.Transports.Streaming.StreamProtocol do
399407

400408
@spec sse_field(binary(), binary()) :: binary() | nil
401409
def sse_field(block, name) do
402-
prefix = name <> ": "
410+
prefix = name <> ":"
403411

404412
block
405413
|> String.split("\n")
406414
|> Enum.map(&String.trim/1)
407-
|> Enum.find_value(fn line ->
408-
if String.starts_with?(line, prefix), do: String.replace_prefix(line, prefix, "")
415+
|> Enum.flat_map(fn line ->
416+
if String.starts_with?(line, prefix) do
417+
[line |> String.replace_prefix(prefix, "") |> String.trim_leading()]
418+
else
419+
[]
420+
end
409421
end)
422+
|> case do
423+
[] -> nil
424+
values -> Enum.join(values, "\n")
425+
end
410426
end
411427

412428
@spec decode_sse_data(term()) :: map()

lib/codex_pooler/gateway/transports/streaming/stream_protocol/public_responses.ex

Lines changed: 179 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,36 @@ defmodule CodexPooler.Gateway.Transports.Streaming.StreamProtocol.PublicResponse
55
alias CodexPooler.Gateway.Runtime.Streaming.BufferTelemetry
66
alias CodexPooler.Gateway.Transports.Streaming.StreamProtocol
77

8+
@type passthrough_terminal_state :: %{
9+
required(:event_type) => String.t(),
10+
required(:json_started?) => boolean(),
11+
required(:depth) => non_neg_integer(),
12+
required(:in_string?) => boolean(),
13+
required(:escaped?) => boolean(),
14+
required(:complete?) => boolean()
15+
}
816
@type state :: %{
917
required(:buffer) => binary(),
1018
required(:created?) => boolean(),
1119
required(:text_delta?) => boolean(),
12-
required(:passthrough?) => boolean()
20+
required(:passthrough?) => boolean(),
21+
required(:passthrough_terminal) => passthrough_terminal_state() | nil,
22+
required(:passthrough_terminal_kind) => atom() | nil,
23+
required(:passthrough_terminal_seen?) => boolean()
1324
}
1425

1526
@spec new_state() :: state()
16-
def new_state, do: %{buffer: "", created?: false, text_delta?: false, passthrough?: false}
27+
def new_state do
28+
%{
29+
buffer: "",
30+
created?: false,
31+
text_delta?: false,
32+
passthrough?: false,
33+
passthrough_terminal: nil,
34+
passthrough_terminal_kind: nil,
35+
passthrough_terminal_seen?: false
36+
}
37+
end
1738

1839
@terminal_buffer_markers [
1940
"data: [DONE]",
@@ -40,12 +61,12 @@ defmodule CodexPooler.Gateway.Transports.Streaming.StreamProtocol.PublicResponse
4061

4162
blocks == [] and StreamProtocol.oversized_incomplete_sse_block?(buffer) ->
4263
record_oversized_incomplete(byte_size(buffered_data))
43-
{buffered_data, %{state | buffer: "", passthrough?: true}}
64+
{buffered_data, enter_passthrough(buffered_data, state)}
4465

4566
StreamProtocol.oversized_incomplete_sse_block?(buffer) ->
4667
record_oversized_incomplete(byte_size(buffered_data))
4768
{iodata, state} = normalize_complete_blocks(blocks, state)
48-
{[iodata, buffer] |> IO.iodata_to_binary(), %{state | buffer: "", passthrough?: true}}
69+
{[iodata, buffer] |> IO.iodata_to_binary(), enter_passthrough(buffer, state)}
4970

5071
true ->
5172
normalize_blocks(blocks, buffer, state)
@@ -61,13 +82,165 @@ defmodule CodexPooler.Gateway.Transports.Streaming.StreamProtocol.PublicResponse
6182
passthrough = binary_part(data, 0, passthrough_size)
6283
rest = binary_part(data, passthrough_size, byte_size(data) - passthrough_size)
6384

64-
state = %{state | passthrough?: false, buffer: ""}
85+
state =
86+
state
87+
|> track_passthrough_terminal_data(passthrough)
88+
|> then(&%{&1 | passthrough?: false, buffer: "", passthrough_terminal: nil})
89+
6590
{normalized_rest, state} = normalize_data(rest, state)
6691

6792
{[passthrough, normalized_rest] |> IO.iodata_to_binary(), state}
6893

6994
nil ->
70-
{data, state}
95+
{data, track_passthrough_terminal_data(state, data)}
96+
end
97+
end
98+
99+
@spec passthrough_terminal_kind(state()) :: atom() | nil
100+
def passthrough_terminal_kind(%{passthrough_terminal_kind: kind}) when is_atom(kind), do: kind
101+
def passthrough_terminal_kind(_state), do: nil
102+
103+
defp enter_passthrough(data, state) do
104+
data
105+
|> passthrough_terminal_event_type()
106+
|> case do
107+
event_type when is_binary(event_type) ->
108+
state
109+
|> Map.put(:passthrough_terminal, new_passthrough_terminal(event_type))
110+
|> track_passthrough_terminal_data(data)
111+
112+
nil ->
113+
%{state | passthrough_terminal: nil}
114+
end
115+
|> then(&%{&1 | buffer: "", passthrough?: true})
116+
end
117+
118+
defp new_passthrough_terminal(event_type) do
119+
%{
120+
event_type: event_type,
121+
json_started?: false,
122+
depth: 0,
123+
in_string?: false,
124+
escaped?: false,
125+
complete?: false
126+
}
127+
end
128+
129+
defp passthrough_terminal_event_type(data) do
130+
{event_type, decoded} = stream_block_event(data)
131+
type = event_type || decoded_string(decoded, "type") || terminal_marker_type(data)
132+
133+
if terminal_event?(type), do: type
134+
end
135+
136+
defp terminal_marker_type(data) when is_binary(data) do
137+
cond do
138+
terminal_marker?(data, "response.completed") -> "response.completed"
139+
terminal_marker?(data, "response.failed") -> "response.failed"
140+
terminal_marker?(data, "response.incomplete") -> "response.incomplete"
141+
terminal_marker?(data, "error") -> "error"
142+
true -> nil
143+
end
144+
end
145+
146+
defp terminal_marker?(data, type) do
147+
String.contains?(data, "event: #{type}") or
148+
String.contains?(data, "event:#{type}") or
149+
String.contains?(data, ~s("type":"#{type}")) or
150+
String.contains?(data, ~s("type": "#{type}"))
151+
end
152+
153+
defp track_passthrough_terminal_data(
154+
%{passthrough_terminal: %{complete?: false} = terminal} = state,
155+
data
156+
)
157+
when is_binary(data) do
158+
terminal = scan_passthrough_terminal_json(data, terminal)
159+
160+
state
161+
|> Map.put(:passthrough_terminal, terminal)
162+
|> maybe_put_passthrough_terminal_kind(terminal)
163+
|> Map.put(
164+
:passthrough_terminal_seen?,
165+
state.passthrough_terminal_seen? or terminal.complete?
166+
)
167+
end
168+
169+
defp track_passthrough_terminal_data(state, _data), do: state
170+
171+
defp maybe_put_passthrough_terminal_kind(state, %{complete?: true, event_type: event_type}) do
172+
Map.put(state, :passthrough_terminal_kind, terminal_kind(event_type))
173+
end
174+
175+
defp maybe_put_passthrough_terminal_kind(state, _terminal), do: state
176+
177+
defp terminal_kind("response.completed"), do: :completed
178+
defp terminal_kind("response.incomplete"), do: :incomplete
179+
defp terminal_kind(_event_type), do: :failed
180+
181+
defp scan_passthrough_terminal_json(data, terminal),
182+
do: scan_passthrough_terminal_json(data, 0, terminal)
183+
184+
defp scan_passthrough_terminal_json(data, offset, terminal)
185+
when offset >= byte_size(data) or terminal.complete?,
186+
do: terminal
187+
188+
defp scan_passthrough_terminal_json(data, offset, %{json_started?: false} = terminal) do
189+
case :binary.at(data, offset) do
190+
?{ ->
191+
scan_passthrough_terminal_json(data, offset + 1, %{
192+
terminal
193+
| json_started?: true,
194+
depth: 1
195+
})
196+
197+
_byte ->
198+
scan_passthrough_terminal_json(data, offset + 1, terminal)
199+
end
200+
end
201+
202+
defp scan_passthrough_terminal_json(
203+
data,
204+
offset,
205+
%{in_string?: true, escaped?: true} = terminal
206+
) do
207+
scan_passthrough_terminal_json(data, offset + 1, %{terminal | escaped?: false})
208+
end
209+
210+
defp scan_passthrough_terminal_json(
211+
data,
212+
offset,
213+
%{in_string?: true, escaped?: false} = terminal
214+
) do
215+
case :binary.at(data, offset) do
216+
?\\ -> scan_passthrough_terminal_json(data, offset + 1, %{terminal | escaped?: true})
217+
?" -> scan_passthrough_terminal_json(data, offset + 1, %{terminal | in_string?: false})
218+
_byte -> scan_passthrough_terminal_json(data, offset + 1, terminal)
219+
end
220+
end
221+
222+
defp scan_passthrough_terminal_json(data, offset, terminal) do
223+
case :binary.at(data, offset) do
224+
?" ->
225+
scan_passthrough_terminal_json(data, offset + 1, %{terminal | in_string?: true})
226+
227+
?{ ->
228+
scan_passthrough_terminal_json(data, offset + 1, %{
229+
terminal
230+
| depth: terminal.depth + 1
231+
})
232+
233+
?} when terminal.depth <= 1 ->
234+
%{terminal | depth: 0, complete?: true}
235+
236+
?} ->
237+
scan_passthrough_terminal_json(data, offset + 1, %{
238+
terminal
239+
| depth: terminal.depth - 1
240+
})
241+
242+
_byte ->
243+
scan_passthrough_terminal_json(data, offset + 1, terminal)
71244
end
72245
end
73246

0 commit comments

Comments
 (0)