feat: harden public OpenAI API contracts
This commit is contained in:
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user