Skip to content

feat(google): support mixing function tools with Gemini's server-side provider tools (Google Search, etc.)#6016

Open
Dhul-Husni wants to merge 4 commits into
livekit:mainfrom
Dhul-Husni:google-support-mixed-tools
Open

feat(google): support mixing function tools with Gemini's server-side provider tools (Google Search, etc.)#6016
Dhul-Husni wants to merge 4 commits into
livekit:mainfrom
Dhul-Husni:google-support-mixed-tools

Conversation

@Dhul-Husni

@Dhul-Husni Dhul-Husni commented Jun 8, 2026

Copy link
Copy Markdown

Summary

  • support mixing function tools with Gemini's server-side provider tools (Google Search, etc.) on the Gemini 3 Developer API, via include_server_side_tool_invocations

Fixes:

Related:

Evidence

google-mixed-tools-demo.mp4

… provider tools (Google Search, etc.) on the Gemini 3 Developer API
devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 new potential issues.

View 7 additional findings in Devin Review.

Open in Devin Review

Comment on lines +527 to +534
if part.text and not part.thought:
retryable = False
self._event_ch.send_nowait(chat_chunk)
self._event_ch.send_nowait(
llm.ChatChunk(
id=request_id,
delta=llm.ChoiceDelta(role="assistant", content=part.text),
)
)

@devin-ai-integration devin-ai-integration Bot Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Function call parts with co-occurring text now leak text as content

The old _parse_part method checked if part.function_call: first and returned immediately, ignoring any co-occurring part.text. The new streaming loop checks if part.text and not part.thought: independently of whether part.function_call is also set. If a Gemini response part has both function_call and text (the now-deleted test test_function_call_with_text_returns_none_content explicitly tested this with text="get_weather"), the text is emitted as assistant content to the user before the function call is emitted post-loop. This surfaces unexpected/duplicate text (e.g., the function name itself) as visible assistant output.

Old vs new behavior for a part with both function_call and text

Old: _parse_part entered the if part.function_call: branch, returned a function-call-only chunk, text was silently dropped.

New: The streaming loop emits a text content chunk (line 527-534), then after the loop the function call is also emitted (line 555-570). The consumer sees both.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

part.text and part.function_call are mutually exclusive:

Source: google/ai/generativelanguage/v1beta/content.proto (googleapis)

  message Part {
    oneof data {
      string text = 2;
      Blob inline_data = 3;
      FunctionCall function_call = 4;
      FunctionResponse function_response = 5;
      FileData file_data = 6;
      ExecutableCode executable_code = 9;
      CodeExecutionResult code_execution_result = 10;
    }

    bool thought = 11;
    bytes thought_signature = 13;
  }

Comment thread livekit-plugins/livekit-plugins-google/livekit/plugins/google/llm.py Outdated
Comment on lines +321 to +333
if function_calling_config or retrieval_config or include_server_side_tool_invocations:
extra["tool_config"] = types.ToolConfig(
function_calling_config=function_calling_config,
retrieval_config=retrieval_config,
include_server_side_tool_invocations=include_server_side_tool_invocations
or None,
)
extra["tool_config"] = gemini_tool_choice
elif retrieval_config:
extra["tool_config"] = types.ToolConfig(
retrieval_config=retrieval_config,
)

if tools_config := create_tools_config(
tool_ctx,
_only_single_type=drop_provider_tools or tool_choice == "none",
):
extra["tools"] = tools_config

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 tool_config sent without tools in edge case (tool_choice='none' + only provider tools)

When tool_choice="none" and only provider tools exist (no function tools), function_calling_config is set to FunctionCallingConfig(mode=NONE) which is truthy, so a tool_config is included in the request. But create_tools_config returns an empty list (due to _only_single_type=True and no function tools), so no tools are set. This sends tool_config with NONE mode but no corresponding tools. The Gemini API may tolerate this (it's essentially a no-op config), and the test test_tool_choice_none_sends_no_provider_tools validates config.tools is None but doesn't check tool_config. Worth confirming the API doesn't reject this combination.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no-op

  client.models.generate_content(
      model="gemini-3.5-flash",
      contents="Say hello in one word.",
      config=GenerateContentConfig(
          tool_config=ToolConfig(
              function_calling_config=FunctionCallingConfig(mode=NONE)
          ),
      ),
  )
  # -> "Hello"  (HTTP 200)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant