fix(gemini realtime): scope dotted-key event lookup and propagate session metadata to tool-call response.done

- map_openai_event: only check the current key/value pair when resolving
  dotted map entries (e.g. serverContent.turnComplete) so a sibling key in
  the same frame can't misclassify the event being processed
  (e.g. toolCall returning RESPONSE_DONE).
- tool-call path: extract generationConfig once and include modalities,
  temperature, and max_output_tokens on response.done so its shape matches
  response.created and the non-tool-call response.done.

Co-authored-by: Yassin Kortam <yassin@berri.ai>
This commit is contained in:
Cursor Agent 2026-05-22 23:02:48 +00:00
parent 2c41400bdf
commit 61928b9704
No known key found for this signature in database

View file

@ -1152,15 +1152,25 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
else:
# Check if this key or any nested key matches our mapping. Use a
# distinct loop variable so we don't shadow ``openai_event`` and
# leak the last dict value when no entry matches.
# leak the last dict value when no entry matches. Scope dotted-key
# lookups to the current ``key``/``value`` pair — checking the
# whole ``json_message`` would let a sibling key (e.g.
# ``serverContent.turnComplete``) misclassify the event currently
# being processed (e.g. ``toolCall``).
for map_key, candidate_event in MAP_GEMINI_FIELD_TO_OPENAI_EVENT.items():
if map_key == key or (
"." in map_key
and GeminiRealtimeConfig.get_nested_value(json_message, map_key)
is not None
):
if map_key == key:
openai_event = candidate_event
break
if "." in map_key:
prefix, _, nested_path = map_key.partition(".")
if (
prefix == key
and isinstance(value, dict)
and GeminiRealtimeConfig.get_nested_value(value, nested_path)
is not None
):
openai_event = candidate_event
break
if openai_event is None:
raise ValueError(f"Unknown openai event: {key}, value: {value}")
return openai_event
@ -1311,6 +1321,30 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
if current_conversation_id is None:
current_conversation_id = f"conv_{uuid.uuid4()}"
# Extract session-level response metadata once so both
# response.created and response.done can include matching
# modalities/temperature/max_output_tokens fields.
session_setup: BidiGenerateContentSetup = {}
if session_configuration_request is not None:
try:
session_setup = json.loads(session_configuration_request).get(
"setup", {}
)
except (json.JSONDecodeError, TypeError):
session_setup = {}
tool_call_generation_config = (
session_setup.get("generationConfig", {}) or {}
)
tool_call_modalities = [
modality.lower()
for modality in cast(
List[str],
tool_call_generation_config.get(
"responseModalities", ["AUDIO"]
),
)
]
# Emit response.created preamble if this is the first event in the response
if current_response_id is None:
current_response_id = f"resp_{uuid.uuid4()}"
@ -1321,26 +1355,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
# spec-compliant clients see consistent response metadata
# regardless of whether the response starts with content or
# a tool call.
session_setup: BidiGenerateContentSetup = {}
if session_configuration_request is not None:
try:
session_setup = json.loads(
session_configuration_request
).get("setup", {})
except (json.JSONDecodeError, TypeError):
session_setup = {}
tool_call_generation_config = (
session_setup.get("generationConfig", {}) or {}
)
tool_call_modalities = [
modality.lower()
for modality in cast(
List[str],
tool_call_generation_config.get(
"responseModalities", ["AUDIO"]
),
)
]
returned_message.append(
{
"type": "response.created",
@ -1420,31 +1434,43 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
tool_call_responses_api_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage(
get_empty_usage(),
)
returned_message.append(
OpenAIRealtimeDoneEvent(
type="response.done",
event_id=f"event_{uuid.uuid4()}",
response=OpenAIRealtimeResponseDoneObject(
id=current_response_id,
object="realtime.response",
status="completed",
output=[
{
"id": te["item_id"],
"object": "realtime.item",
"type": "function_call",
"status": "completed",
"call_id": te["call_id"],
"name": te["name"],
"arguments": te["arguments"],
}
for te in tool_call_events
],
conversation_id=current_conversation_id,
usage=tool_call_responses_api_usage.model_dump(),
),
)
tool_call_done_event = OpenAIRealtimeDoneEvent(
type="response.done",
event_id=f"event_{uuid.uuid4()}",
response=OpenAIRealtimeResponseDoneObject(
id=current_response_id,
object="realtime.response",
status="completed",
output=[
{
"id": te["item_id"],
"object": "realtime.item",
"type": "function_call",
"status": "completed",
"call_id": te["call_id"],
"name": te["name"],
"arguments": te["arguments"],
}
for te in tool_call_events
],
conversation_id=current_conversation_id,
modalities=tool_call_modalities,
usage=tool_call_responses_api_usage.model_dump(),
),
)
tool_call_temperature = tool_call_generation_config.get("temperature")
if tool_call_temperature is not None:
tool_call_done_event["response"][
"temperature"
] = tool_call_temperature
tool_call_max_output_tokens = tool_call_generation_config.get(
"maxOutputTokens"
)
if tool_call_max_output_tokens is not None:
tool_call_done_event["response"][
"max_output_tokens"
] = tool_call_max_output_tokens
returned_message.append(tool_call_done_event)
# Reset IDs so the next model turn (after tool results) starts a
# fresh response with its own response.created preamble.
current_output_item_id = None