Skip to content

Commit cfb636d

Browse files
committed
fix(gateway): settle terminal response usage
1 parent a8cbcca commit cfb636d

2 files changed

Lines changed: 179 additions & 25 deletions

File tree

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

Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -27,19 +27,17 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.ResponseUsage do
2727

2828
@spec from_websocket_body(binary()) :: usage()
2929
def from_websocket_body(body) when is_binary(body) do
30-
case usage_from_sse_lines(body) do
31-
%{status: "usage_known"} = usage ->
32-
usage
33-
34-
_missing ->
35-
from_delimited_json_messages(body) ||
36-
usage_from_retained_usage_fragment(body) ||
37-
%{status: "usage_unknown", source: "websocket_usage_missing"}
30+
line_or_message_usage =
31+
best_usage(usage_from_sse_lines(body), from_delimited_json_messages(body))
32+
33+
case best_usage(line_or_message_usage, usage_from_retained_usage_fragment(body)) do
34+
%{status: "usage_known"} = usage -> usage
35+
_missing -> %{status: "usage_unknown", source: "websocket_usage_missing"}
3836
end
3937
end
4038

4139
defp from_sse_body(body, missing_source) do
42-
usage_from_sse_lines(body) || usage_from_retained_usage_fragment(body) ||
40+
best_usage(usage_from_sse_lines(body), usage_from_retained_usage_fragment(body)) ||
4341
%{status: "usage_unknown", source: missing_source}
4442
end
4543

@@ -54,14 +52,33 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.ResponseUsage do
5452
end
5553

5654
defp usage_from_retained_usage_fragment(body) do
57-
with {:ok, input_tokens} <- retained_int(body, ~r/"input_tokens"\s*:\s*(\d+)/),
58-
{:ok, output_tokens} <- retained_int(body, ~r/"output_tokens"\s*:\s*(\d+)/),
59-
{:ok, total_tokens} <- retained_int(body, ~r/"total_tokens"\s*:\s*(\d+)/) do
55+
body
56+
|> retained_usage_candidates()
57+
|> Enum.reduce(nil, fn candidate, acc ->
58+
case usage_from_retained_usage_candidate(candidate) do
59+
%{status: "usage_known"} = usage -> usage
60+
_missing -> acc
61+
end
62+
end)
63+
end
64+
65+
defp retained_usage_candidates(body) do
66+
~r/"usage"\s*:/
67+
|> Regex.scan(body, return: :index)
68+
|> Enum.map(fn [{offset, _length} | _captures] ->
69+
binary_part(body, offset, byte_size(body) - offset)
70+
end)
71+
end
72+
73+
defp usage_from_retained_usage_candidate(candidate) do
74+
with {:ok, input_tokens} <- retained_int(candidate, ~r/"input_tokens"\s*:\s*(\d+)/),
75+
{:ok, output_tokens} <- retained_int(candidate, ~r/"output_tokens"\s*:\s*(\d+)/),
76+
{:ok, total_tokens} <- retained_int(candidate, ~r/"total_tokens"\s*:\s*(\d+)/) do
6077
%{
6178
"input_tokens" => input_tokens,
62-
"cached_input_tokens" => retained_cached_input_tokens(body),
79+
"cached_input_tokens" => retained_cached_input_tokens(candidate),
6380
"output_tokens" => output_tokens,
64-
"reasoning_tokens" => retained_int_or_zero(body, ~r/"reasoning_tokens"\s*:\s*(\d+)/),
81+
"reasoning_tokens" => retained_int_or_zero(candidate, ~r/"reasoning_tokens"\s*:\s*(\d+)/),
6582
"total_tokens" => total_tokens
6683
}
6784
|> normalize_usage(%{})
@@ -99,10 +116,10 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.ResponseUsage do
99116
end
100117

101118
defp usage_from_json_lines(lines) do
102-
Enum.find_value(lines, fn line ->
119+
Enum.reduce(lines, nil, fn line, acc ->
103120
case Jason.decode(line) do
104-
{:ok, decoded} -> usage_from_decoded(decoded, false)
105-
{:error, _reason} -> nil
121+
{:ok, decoded} -> latest_usage_candidate(decoded, acc)
122+
{:error, _reason} -> acc
106123
end
107124
end)
108125
end
@@ -117,17 +134,62 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.ResponseUsage do
117134
do: normalize_usage(usage, response)
118135

119136
defp usage_from_decoded(%{"output" => output}, default) when is_list(output) do
120-
Enum.find_value(output, &usage_from_decoded(&1, false)) || maybe_default_usage(default)
137+
latest_usage(output) || maybe_default_usage(default)
121138
end
122139

123140
defp usage_from_decoded(_decoded, default), do: maybe_default_usage(default)
124141

142+
defp latest_usage(items) do
143+
Enum.reduce(items, nil, fn item, acc ->
144+
case usage_from_decoded(item, false) do
145+
%{status: "usage_known"} = usage -> usage
146+
%{status: "usage_unknown"} = usage -> acc || usage
147+
nil -> acc
148+
end
149+
end)
150+
end
151+
152+
defp latest_usage_candidate(decoded, acc) do
153+
case usage_from_decoded(decoded, false) do
154+
%{status: "usage_known"} = usage -> usage
155+
%{status: "usage_unknown"} = usage -> acc || usage
156+
nil -> acc
157+
end
158+
end
159+
160+
defp best_usage(nil, nil), do: nil
161+
162+
defp best_usage(%{status: "usage_known"} = usage, nil), do: usage
163+
defp best_usage(nil, %{status: "usage_known"} = usage), do: usage
164+
165+
defp best_usage(
166+
%{status: "usage_known"} = line_usage,
167+
%{status: "usage_known"} = fragment_usage
168+
) do
169+
if total_tokens(fragment_usage) > total_tokens(line_usage),
170+
do: fragment_usage,
171+
else: line_usage
172+
end
173+
174+
defp best_usage(%{status: "usage_unknown"}, %{status: "usage_known"} = usage), do: usage
175+
defp best_usage(%{status: "usage_known"} = usage, %{status: "usage_unknown"}), do: usage
176+
defp best_usage(%{status: "usage_unknown"} = usage, nil), do: usage
177+
defp best_usage(nil, %{status: "usage_unknown"} = usage), do: usage
178+
defp best_usage(%{status: "usage_unknown"} = usage, _fragment_usage), do: usage
179+
defp best_usage(_line_usage, %{status: "usage_unknown"} = usage), do: usage
180+
181+
defp total_tokens(%{total_tokens: total_tokens}) when is_integer(total_tokens), do: total_tokens
182+
defp total_tokens(_usage), do: 0
183+
125184
defp normalize_usage(usage, envelope) do
126-
with {:ok, input_tokens} <- int_value(usage["input_tokens"] || usage["prompt_tokens"]),
127-
{:ok, cached_input_tokens} <- int_value(cached_input_tokens(usage)),
128-
{:ok, output_tokens} <- int_value(usage["output_tokens"] || usage["completion_tokens"]),
129-
{:ok, reasoning_tokens} <- int_value(usage["reasoning_tokens"]),
130-
{:ok, total_tokens} <- int_value(usage["total_tokens"]) do
185+
with {:ok, input_tokens} <-
186+
required_int_value(usage["input_tokens"] || usage["prompt_tokens"]),
187+
{:ok, cached_input_tokens} <- optional_int_value(cached_input_tokens(usage)),
188+
{:ok, output_tokens} <-
189+
required_int_value(usage["output_tokens"] || usage["completion_tokens"]),
190+
{:ok, reasoning_tokens} <- optional_int_value(usage["reasoning_tokens"]),
191+
{:ok, total_tokens} <-
192+
total_tokens_value(usage["total_tokens"], input_tokens, output_tokens) do
131193
%{
132194
status: "usage_known",
133195
source: "upstream_usage",
@@ -160,7 +222,18 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.ResponseUsage do
160222
defp maybe_default_usage(true), do: %{status: "usage_unknown", source: "usage_missing"}
161223
defp maybe_default_usage(false), do: nil
162224

163-
defp int_value(nil), do: {:ok, 0}
225+
defp total_tokens_value(nil, input_tokens, output_tokens),
226+
do: {:ok, input_tokens + output_tokens}
227+
228+
defp total_tokens_value(value, _input_tokens, _output_tokens), do: required_int_value(value)
229+
230+
defp required_int_value(nil), do: :error
231+
defp required_int_value(value), do: int_value(value)
232+
233+
defp optional_int_value(nil), do: {:ok, 0}
234+
defp optional_int_value(value), do: int_value(value)
235+
236+
defp int_value(nil), do: :error
164237
defp int_value(value) when is_integer(value), do: {:ok, value}
165238
defp int_value(value) when is_float(value), do: :error
166239

test/codex_pooler/gateway/runtime/response_usage_test.exs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.ResponseUsageTest do
3434
Jason.encode!(%{
3535
"output" => [
3636
%{"type" => "message"},
37+
%{
38+
"response" => %{
39+
"service_tier" => "default",
40+
"usage" => %{
41+
"prompt_tokens" => 0,
42+
"prompt_tokens_details" => %{"cached_tokens" => 0},
43+
"completion_tokens" => 0,
44+
"total_tokens" => 0
45+
}
46+
}
47+
},
3748
%{
3849
"response" => %{
3950
"service_tier" => "default",
@@ -75,11 +86,14 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.ResponseUsageTest do
7586
end
7687

7788
describe "from_sse/1" do
78-
test "extracts first valid usage payload from SSE data frames" do
89+
test "extracts latest valid usage payload from SSE data frames" do
7990
body = """
8091
event: ping
8192
data: nope
8293
94+
event: response.in_progress
95+
data: {"response":{"service_tier":"default","usage":{"input_tokens":0,"cached_input_tokens":0,"output_tokens":0,"total_tokens":0}}}
96+
8397
event: response.completed
8498
data: {"response":{"service_tier":"flex","usage":{"input_tokens":3,"cached_input_tokens":2,"output_tokens":4,"total_tokens":7}}}
8599
@@ -146,6 +160,35 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.ResponseUsageTest do
146160
}
147161
end
148162

163+
test "prefers retained terminal usage over earlier zero-token SSE usage" do
164+
body =
165+
sse_event("response.in_progress", %{
166+
"response" => %{
167+
"usage" => %{
168+
"input_tokens" => 0,
169+
"cached_input_tokens" => 0,
170+
"output_tokens" => 0,
171+
"reasoning_tokens" => 0,
172+
"total_tokens" => 0
173+
}
174+
}
175+
}) <>
176+
~s(output_text":"truncated prefix) <>
177+
~s(","usage":{"input_tokens":16086,"input_tokens_details":{"cached_tokens":0},"output_tokens":117,"reasoning_tokens":0,"total_tokens":16203},"status":"completed"}}\n\n) <>
178+
"data: [DONE]\n\n"
179+
180+
assert ResponseUsage.from_sse(body) == %{
181+
status: "usage_known",
182+
source: "upstream_usage",
183+
input_tokens: 16_086,
184+
cached_input_tokens: 0,
185+
output_tokens: 117,
186+
reasoning_tokens: 0,
187+
total_tokens: 16_203,
188+
service_tier: nil
189+
}
190+
end
191+
149192
test "marks SSE without usage as unknown" do
150193
body = ~S"""
151194
data: {"type":"response.created"}
@@ -157,6 +200,44 @@ defmodule CodexPooler.Gateway.Runtime.Finalization.ResponseUsageTest do
157200
assert ResponseUsage.from_sse(body) ==
158201
%{status: "usage_unknown", source: "sse_usage_missing"}
159202
end
203+
204+
test "marks empty terminal usage maps as unknown" do
205+
body =
206+
sse_event("response.completed", %{
207+
"type" => "response.completed",
208+
"response" => %{"usage" => %{}}
209+
})
210+
211+
assert ResponseUsage.from_sse(body) ==
212+
%{status: "usage_unknown", source: "invalid_usage_tokens"}
213+
end
214+
215+
test "keeps explicit zero token usage as known" do
216+
body =
217+
sse_event("response.completed", %{
218+
"type" => "response.completed",
219+
"response" => %{
220+
"usage" => %{
221+
"input_tokens" => 0,
222+
"cached_input_tokens" => 0,
223+
"output_tokens" => 0,
224+
"reasoning_tokens" => 0,
225+
"total_tokens" => 0
226+
}
227+
}
228+
})
229+
230+
assert ResponseUsage.from_sse(body) == %{
231+
status: "usage_known",
232+
source: "upstream_usage",
233+
input_tokens: 0,
234+
cached_input_tokens: 0,
235+
output_tokens: 0,
236+
reasoning_tokens: 0,
237+
total_tokens: 0,
238+
service_tier: nil
239+
}
240+
end
160241
end
161242

162243
describe "from_websocket_body/1" do

0 commit comments

Comments
 (0)