Skip to content

Commit f8ff8e3

Browse files
authored
feat: report OpenAI finish reason distribution (#422)
* feat: report OpenAI finish reasons * refactor: enumerate finish reason counters
1 parent 598dbfd commit f8ff8e3

10 files changed

Lines changed: 128 additions & 6 deletions

File tree

‎src/inference_endpoint/async_utils/services/metrics_aggregator/aggregator.py‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ class MetricCounterKey(str, Enum):
6969
# session.py:_handle_response emitting ERROR before COMPLETE so the
7070
# tracked row still exists when the aggregator sees the ERROR.
7171
TRACKED_SAMPLES_FAILED = "tracked_samples_failed"
72+
TRACKED_FINISH_REASON_STOP = "tracked_finish_reason_stop"
73+
TRACKED_FINISH_REASON_LENGTH = "tracked_finish_reason_length"
74+
TRACKED_FINISH_REASON_TOOL_CALLS = "tracked_finish_reason_tool_calls"
75+
TRACKED_FINISH_REASON_CONTENT_FILTER = "tracked_finish_reason_content_filter"
76+
TRACKED_FINISH_REASON_FUNCTION_CALL = "tracked_finish_reason_function_call"
77+
TRACKED_FINISH_REASON_OTHER = "tracked_finish_reason_other"
7278
TRACKED_DURATION_NS = "tracked_duration_ns"
7379
# Legacy MLPerf LoadGen Server "completed" window (final_query_all_samples_done_time).
7480
LEGACY_LOADGEN_WINDOW_DURATION_NS = "legacy_loadgen_window_duration_ns"
@@ -80,6 +86,15 @@ class MetricCounterKey(str, Enum):
8086
TOTAL_DURATION_NS = "total_duration_ns"
8187

8288

89+
_FINISH_REASON_COUNTERS: Final[dict[str, MetricCounterKey]] = {
90+
"stop": MetricCounterKey.TRACKED_FINISH_REASON_STOP,
91+
"length": MetricCounterKey.TRACKED_FINISH_REASON_LENGTH,
92+
"tool_calls": MetricCounterKey.TRACKED_FINISH_REASON_TOOL_CALLS,
93+
"content_filter": MetricCounterKey.TRACKED_FINISH_REASON_CONTENT_FILTER,
94+
"function_call": MetricCounterKey.TRACKED_FINISH_REASON_FUNCTION_CALL,
95+
}
96+
97+
8398
_TRACKED_SAMPLE_EVENTS = frozenset(
8499
{
85100
SampleEventType.ISSUED,
@@ -397,6 +412,12 @@ async def process(self, records: list[EventRecord]) -> None:
397412
registry.increment(MetricCounterKey.TOTAL_SAMPLES_COMPLETED.value)
398413
if is_tracked:
399414
registry.increment(MetricCounterKey.TRACKED_SAMPLES_COMPLETED.value)
415+
if isinstance(record.finish_reason, str):
416+
counter = _FINISH_REASON_COUNTERS.get(
417+
record.finish_reason,
418+
MetricCounterKey.TRACKED_FINISH_REASON_OTHER,
419+
)
420+
registry.increment(counter.value)
400421

401422
if saw_shutdown:
402423
# ENDED has been observed; transition to DRAINING so any tick

‎src/inference_endpoint/core/record.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ class EventRecord(msgspec.Struct, kw_only=True, frozen=True, gc=False): # type:
156156
conversation_id: str = ""
157157
turn: int | None = None
158158
data: OUTPUT_TYPE | PromptData | ErrorData | None = None
159+
finish_reason: str | msgspec.UnsetType = msgspec.UNSET
159160

160161

161162
class EventRecordCodec:

‎src/inference_endpoint/load_generator/session.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
from enum import Enum
3131
from typing import Any, Protocol
3232

33+
import msgspec
34+
3335
from ..config.runtime_settings import RuntimeSettings
3436
from ..config.schema import LoadPatternType
3537
from ..core.record import (
@@ -601,6 +603,7 @@ def _handle_response(self, resp: QueryResult | StreamChunk) -> None:
601603
)
602604
)
603605
if self._current_phase_type != PhaseType.WARMUP:
606+
finish_reason = resp.metadata.get("finish_reason")
604607
self._publisher.publish(
605608
EventRecord(
606609
event_type=SampleEventType.COMPLETE,
@@ -611,6 +614,11 @@ def _handle_response(self, resp: QueryResult | StreamChunk) -> None:
611614
conversation_id=conv_id_str,
612615
turn=turn_num,
613616
data=resp.response_output,
617+
finish_reason=(
618+
finish_reason
619+
if isinstance(finish_reason, str)
620+
else msgspec.UNSET
621+
),
614622
)
615623
)
616624

‎src/inference_endpoint/metrics/report.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
import msgspec.json
2727
import msgspec.structs
2828

29+
from inference_endpoint.async_utils.services.metrics_aggregator.aggregator import (
30+
MetricCounterKey,
31+
)
2932
from inference_endpoint.async_utils.services.metrics_aggregator.registry import (
3033
build_token_series_dict,
3134
)
@@ -66,6 +69,16 @@ def place_early_stopping_percentiles(
6669
return out
6770

6871

72+
_FINISH_REASON_COUNTERS = (
73+
MetricCounterKey.TRACKED_FINISH_REASON_STOP,
74+
MetricCounterKey.TRACKED_FINISH_REASON_LENGTH,
75+
MetricCounterKey.TRACKED_FINISH_REASON_TOOL_CALLS,
76+
MetricCounterKey.TRACKED_FINISH_REASON_CONTENT_FILTER,
77+
MetricCounterKey.TRACKED_FINISH_REASON_FUNCTION_CALL,
78+
MetricCounterKey.TRACKED_FINISH_REASON_OTHER,
79+
)
80+
81+
6982
def _series_to_metric_dict(stat: dict[str, Any]) -> dict[str, Any]:
7083
"""Convert a series-stat dict into the shape ``display()`` expects.
7184
@@ -204,6 +217,7 @@ class Report(msgspec.Struct, frozen=True): # type: ignore[call-arg]
204217
# tokenizer unavailable).
205218
qps: float | None = None
206219
tps: float | None = None
220+
finish_reason_counts: dict[str, int] = msgspec.field(default_factory=dict)
207221

208222
# Run configuration (load_pattern, warmup, and the scheduler/dataloader RNG
209223
# seeds), from config. Carried so result_summary.json is self-describing and a
@@ -325,6 +339,13 @@ def _series_dict(key: str) -> dict[str, Any]:
325339
# indicator in display().
326340
state = snap.get("state", "interrupted")
327341
n_pending_tasks = snap.get("n_pending_tasks", 0)
342+
finish_reason_prefix = "tracked_finish_reason_"
343+
finish_reason_counts = {
344+
reason.value.removeprefix(finish_reason_prefix): int(
345+
counters.get(reason.value, 0)
346+
)
347+
for reason in _FINISH_REASON_COUNTERS
348+
}
328349

329350
return cls(
330351
version=str(version_info.get("version", "unknown")),
@@ -344,6 +365,7 @@ def _series_dict(key: str) -> dict[str, Any]:
344365
legacy_loadgen_window_duration_ns=legacy_loadgen_window_duration_ns,
345366
qps=qps,
346367
tps=tps,
368+
finish_reason_counts=finish_reason_counts,
347369
run_config=run_config,
348370
)
349371

‎src/inference_endpoint/openai/completions_adapter.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,13 @@ def decode_response(cls, response_bytes: bytes, query_id: str) -> QueryResult:
104104
resp = cls._response_decoder.decode(response_bytes)
105105
if not resp.choices:
106106
raise ValueError("Response must contain at least one choice")
107+
choice = resp.choices[0]
107108
return QueryResult(
108109
id=query_id,
109-
response_output=TextModelOutput(output=resp.choices[0].text),
110+
response_output=TextModelOutput(output=choice.text),
111+
metadata=(
112+
{"finish_reason": choice.finish_reason} if choice.finish_reason else {}
113+
),
110114
)
111115

112116
@classmethod

‎tests/unit/async_utils/services/event_logger/test_file_writer.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,22 @@ def test_writes_valid_jsonl(self, tmp_path):
5656
writer = JSONLWriter(tmp_path / "events", flush_interval=1)
5757
try:
5858
writer.write(_record(SampleEventType.ISSUED, uuid="s1", ts=1000))
59-
writer.write(_record(SampleEventType.COMPLETE, uuid="s1", ts=2000))
59+
writer.write(
60+
EventRecord(
61+
event_type=SampleEventType.COMPLETE,
62+
timestamp_ns=2000,
63+
sample_uuid="s1",
64+
finish_reason="tool_calls",
65+
)
66+
)
6067
finally:
6168
writer.close()
6269

6370
lines = (tmp_path / "events.jsonl").read_text().strip().split("\n")
6471
assert len(lines) == 2
65-
for line in lines:
66-
parsed = json.loads(line)
67-
assert isinstance(parsed, dict)
72+
records = [json.loads(line) for line in lines]
73+
assert "finish_reason" not in records[0]
74+
assert records[1]["finish_reason"] == "tool_calls"
6875

6976
def test_record_roundtrip_fields(self, tmp_path):
7077
writer = JSONLWriter(tmp_path / "events", flush_interval=1)

‎tests/unit/async_utils/services/metrics_aggregator/test_aggregator.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,40 @@ async def test_total_vs_tracked_counters(self, tmp_path):
808808
finally:
809809
agg.close()
810810

811+
@pytest.mark.asyncio
812+
async def test_finish_reason_counters_bucket_unknown_values(self, tmp_path):
813+
loop = asyncio.get_event_loop()
814+
with ManagedZMQContext.scoped(socket_dir=str(tmp_path)) as ctx:
815+
agg, registry, _ = make_aggregator(ctx, loop, "agg_finish_reasons")
816+
try:
817+
await agg.process(
818+
[
819+
session_event(
820+
SessionEventType.START_PERFORMANCE_TRACKING, ts=0
821+
),
822+
sample_event(SampleEventType.ISSUED, "s1", ts=1),
823+
EventRecord(
824+
event_type=SampleEventType.COMPLETE,
825+
timestamp_ns=2,
826+
sample_uuid="s1",
827+
finish_reason="stop",
828+
),
829+
sample_event(SampleEventType.ISSUED, "s2", ts=3),
830+
EventRecord(
831+
event_type=SampleEventType.COMPLETE,
832+
timestamp_ns=4,
833+
sample_uuid="s2",
834+
finish_reason="future_reason",
835+
),
836+
]
837+
)
838+
839+
counters = snapshot_counters(registry)
840+
assert counters[MetricCounterKey.TRACKED_FINISH_REASON_STOP.value] == 1
841+
assert counters[MetricCounterKey.TRACKED_FINISH_REASON_OTHER.value] == 1
842+
finally:
843+
agg.close()
844+
811845

812846
# ---------------------------------------------------------------------------
813847
# Token trigger tests (with mock BatchTokenizer and real event loop)

‎tests/unit/load_generator/test_async_session.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,10 +725,16 @@ async def test_handle_response_stamps_conversation_id_and_turn(self):
725725
phase_issuer.uuid_to_conv_info["q-ok"] = ("conv-9", 5)
726726
phase_issuer.inflight = 1
727727
session._handle_response(
728-
QueryResult(id="q-ok", response_output="ok", completed_at=12345)
728+
QueryResult(
729+
id="q-ok",
730+
response_output="ok",
731+
metadata={"finish_reason": "stop"},
732+
completed_at=12345,
733+
)
729734
)
730735
complete = publisher.events_of_type(SampleEventType.COMPLETE)
731736
assert [(e.conversation_id, e.turn) for e in complete] == [("conv-9", 5)]
737+
assert complete[0].finish_reason == "stop"
732738
assert "q-ok" not in phase_issuer.uuid_to_conv_info
733739
assert "q-ok" not in phase_issuer.completed_uuids
734740

‎tests/unit/metrics/test_report_builder.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,24 @@ def test_failed_uses_tracked_counter(self):
257257
report = _build_report(registry)
258258
assert report.n_samples_failed == 1
259259

260+
def test_finish_reason_counts_include_zeros(self):
261+
registry = _make_registry(n_samples=2)
262+
registry.increment(MetricCounterKey.TRACKED_FINISH_REASON_STOP.value, 1)
263+
registry.increment(MetricCounterKey.TRACKED_FINISH_REASON_LENGTH.value, 1)
264+
265+
report = _build_report(registry)
266+
267+
expected_counts = {
268+
"stop": 1,
269+
"length": 1,
270+
"tool_calls": 0,
271+
"content_filter": 0,
272+
"function_call": 0,
273+
"other": 0,
274+
}
275+
assert report.finish_reason_counts == expected_counts
276+
assert json.loads(report.to_json())["finish_reason_counts"] == expected_counts
277+
260278
def test_complete_flag_true_when_state_complete_and_no_pending(self):
261279
registry = _make_registry(n_samples=5)
262280
report = _build_report(registry, state=SessionState.COMPLETE, n_pending_tasks=0)

‎tests/unit/openai/test_completions_adapter.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ def test_decode_non_streaming(self):
140140
assert result.id == "qid-1"
141141
assert isinstance(result.response_output, TextModelOutput)
142142
assert result.response_output.output == "hello world"
143+
assert result.metadata["finish_reason"] == "stop"
143144

144145
@pytest.mark.unit
145146
def test_decode_empty_choices_raises(self):

0 commit comments

Comments
 (0)