feat: harden public OpenAI API contracts
Quality gate / backend (push) Canceled after 0s
Quality gate / frontend (push) Canceled after 0s

This commit is contained in:
Xin Wang
2026-09-07 14:25:48 +08:00
parent 8025ed8aa0
commit 2649d64b55
36 changed files with 16643 additions and 236 deletions
+82 -12
View File
@@ -28,8 +28,10 @@ from pipecat.frames.frames import (
LLMMessagesAppendFrame,
LLMTextFrame,
ManuallySwitchServiceFrame,
MetricsFrame,
TTSSpeakFrame,
)
from pipecat.metrics.metrics import LLMTokenUsage, LLMUsageMetricsData
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
@@ -63,6 +65,19 @@ class ResponseRunError(RuntimeError):
DeltaCallback = Callable[[str], Awaitable[None]]
def _normalized_tool_arguments(value: object) -> object:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def _tool_arguments_equal(left: object, right: object) -> bool:
return _normalized_tool_arguments(left) == _normalized_tool_arguments(right)
@dataclass(frozen=True)
class ResponseRunResult:
text: str
@@ -70,6 +85,27 @@ class ResponseRunResult:
context_messages: list[dict[str, Any]]
dynamic_variables: dict[str, Any]
runtime_state: dict[str, Any]
usage: dict[str, Any] | None
def _usage_payload(values: list[LLMTokenUsage]) -> dict[str, Any] | None:
"""Combine Provider-reported LLM usage without estimating missing values."""
if not values:
return None
return {
"input_tokens": sum(value.prompt_tokens for value in values),
"input_tokens_details": {
"cached_tokens": sum(
value.cache_read_input_tokens or 0 for value in values
),
},
"output_tokens": sum(value.completion_tokens for value in values),
"output_tokens_details": {
"reasoning_tokens": sum(value.reasoning_tokens or 0 for value in values),
},
"total_tokens": sum(value.total_tokens for value in values),
}
@dataclass
@@ -101,6 +137,7 @@ class _CaptureProcessor(FrameProcessor):
self.window = _ResponseWindow()
self.deltas: list[str] = []
self.outputs: list[str] = []
self.llm_usage: list[LLMTokenUsage] = []
self.error: str | None = None
def reset(self) -> None:
@@ -143,6 +180,12 @@ class _CaptureProcessor(FrameProcessor):
self.error = frame.error or "Pipeline execution failed"
self.window.pending_tools.clear()
self.window.touch()
elif isinstance(frame, MetricsFrame):
self.llm_usage.extend(
metric.value
for metric in frame.data
if isinstance(metric, LLMUsageMetricsData)
)
elif isinstance(frame, _BoundaryFrame):
frame.reached.set()
self.window.touch()
@@ -162,6 +205,13 @@ class ResponsesClientToolPort:
def supports(self, _function_name: str) -> bool:
return True
@staticmethod
def _arguments_match(pending: dict[str, Any], arguments: dict[str, Any]) -> bool:
original = pending.get("arguments")
if original is None:
return True
return _tool_arguments_equal(original, arguments)
async def call(
self,
function_name: str,
@@ -174,7 +224,10 @@ class ResponsesClientToolPort:
) -> dict[str, Any]:
del timeout_seconds, wait_for_response, response_wait_mode, interrupt_on_result
for index, pending in enumerate(self._results):
if pending.get("name") == function_name:
if (
pending.get("name") == function_name
and self._arguments_match(pending, arguments)
):
self._results.pop(index)
return dict(pending.get("result") or {})
item = {
@@ -207,18 +260,29 @@ def _context_snapshot(
]
if fallback_messages:
messages.extend(deepcopy(fallback_messages))
candidates = [
tool_call
for message in messages
for tool_call in message.get("tool_calls") or []
]
used_candidates: set[int] = set()
for public_call in function_calls:
matched = False
for message in reversed(messages):
for tool_call in reversed(message.get("tool_calls") or []):
function = tool_call.get("function") or {}
if function.get("name") != public_call["name"]:
continue
tool_call["id"] = public_call["call_id"]
matched = True
break
if matched:
break
for index, tool_call in enumerate(candidates):
if index in used_candidates:
continue
function = tool_call.get("function") or {}
if function.get("name") != public_call["name"]:
continue
if not _tool_arguments_equal(
function.get("arguments"),
public_call.get("arguments"),
):
continue
tool_call["id"] = public_call["call_id"]
used_candidates.add(index)
matched = True
break
if not matched:
messages.append(
{
@@ -368,7 +432,10 @@ class ResponsesTextRunner:
)
worker = PipelineWorker(
pipeline,
params=PipelineParams(enable_metrics=False),
params=PipelineParams(
enable_metrics=False,
enable_usage_metrics=True,
),
enable_rtvi=False,
enable_turn_tracking=False,
idle_timeout_secs=None,
@@ -562,6 +629,7 @@ class ResponsesTextRunner:
),
dynamic_variables=_public_variables(brain, cfg),
runtime_state=_runtime_state(brain),
usage=_usage_payload(capture.llm_usage),
)
capture.reset()
@@ -589,6 +657,7 @@ class ResponsesTextRunner:
),
dynamic_variables=_public_variables(brain, cfg),
runtime_state=_runtime_state(brain),
usage=_usage_payload(capture.llm_usage),
)
text = (capture.outputs[-1] if capture.outputs else "".join(capture.deltas)).strip()
if not text:
@@ -599,6 +668,7 @@ class ResponsesTextRunner:
context_messages=_context_snapshot(context, function_calls=[]),
dynamic_variables=_public_variables(brain, cfg),
runtime_state=_runtime_state(brain),
usage=_usage_payload(capture.llm_usage),
)
finally:
if not worker.has_finished():