Skip to content

feat(llmhubs): support tool calling and structured output in the Gemi…#45

Open
xinkuleee wants to merge 2 commits into
microsoft:mainfrom
xinkuleee:feat/gemini-tool-calling
Open

feat(llmhubs): support tool calling and structured output in the Gemi…#45
xinkuleee wants to merge 2 commits into
microsoft:mainfrom
xinkuleee:feat/gemini-tool-calling

Conversation

@xinkuleee

Copy link
Copy Markdown

Summary

Adds Google Gemini function/tool calling and JSON-schema structured output to the
LLMHub Gemini adapter, so Gemini models can drive the chat agent (tools) and
structured routing (intent check).

  • Tool calling: map internal (OpenAI-shaped) tools / function_call /
    function_result to Gemini functionDeclarations / functionCall /
    functionResponse (role mapping assistant→model / tool→user, and
    call_id↔function-name correlation since Gemini responses carry no call id);
    parse Gemini functionCall back into function_call outputs.
  • Schema sanitizer: rewrite tool/response JSON Schema into Gemini's
    OpenAPI-subset — inline $ref/$defs, drop additionalProperties/$schema,
    flatten single-element allOf, and convert Optional anyOf + {"type":"null"}
    to nullable.
  • Structured output: map response_format.type=json_schema to Gemini
    responseMimeType + responseSchema (fixes chat intent-check routing on Gemini).
  • Capability flags: advertise supports_tools / supports_structured_output
    for the Gemini provider (core io_profile derivation + backend equivalent).
  • Tests: unit coverage for request building, input/response mapping, the
    sanitizer (refs/defs/additionalProperties/allOf/anyOf-null/recursion), and
    structured output.

Validation

  • make precommit-run
  • cd backend && go test ./...
  • cd core && uv run pytest
  • Frontend build/lint, if working from a frontend source checkout
  • Not run; explain below

Frontend is untouched, so its build/lint does not apply.

Checklist

  • Linked relevant issues or explained why there is no issue.
  • Updated docs and examples where behavior changed.
  • Updated CHANGELOG.md under ## [Unreleased] for user-facing changes.
  • Regenerated and committed generated files after proto, Wire, or OpenAPI changes.
  • Confirmed no secrets, tokens, credentials, or sensitive logs are included.

Notes for reviewers

  • Streaming is not implemented for Gemini yet — it falls back to the base
    non-streaming path (generate_stream yields a single chunk). Tool calling works,
    but without incremental token streaming; a follow-up can add
    streamGenerateContent?alt=sse.
  • The schema sanitizer targets the keywords Gemini currently rejects; verified
    against the full builtin tool set and the structured intent-check schema.
  • No secrets/config included: the local Gemini model YAML (with API key) and the
    local default-model switch are gitignored and intentionally excluded from this PR.

Comment thread core/app/llmhubs/adapters/gemini.py Outdated
outputs.append(
OutputItem(
type="function_call",
call_id=_synthesize_call_id(name, index),

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.

Blocking: This discards Gemini's actual functionCall.id and unconditionally replaces it with an internal synthetic ID. Gemini 3 returns a unique ID for each function call, and the exact ID must be echoed in the corresponding function response. This is also required to disambiguate parallel calls to the same function. Please preserve function_call["id"] when present and use synthesis only as a legacy fallback.

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.

Fixed. _parse_response now preserves Gemini's id when present and only synthesizes as a legacy fallback:

call_id = function_call.get("id") or _synthesize_call_id(name, index)

The real id is carried end-to-end and echoed in the matching functionResponse, so parallel calls to the same function stay disambiguated. Covered by test_parse_response_preserves_real_call_id_and_part and test_parse_response_synthesizes_call_id_when_missing.

Comment thread core/app/llmhubs/adapters/gemini.py Outdated
return mapping


def _content_to_part(c: InputContent, call_id_to_name: dict[str, str]) -> dict[str, Any] | None:

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.

The provider call ID is omitted from both the reconstructed functionCall and the functionResponse. For calls where Gemini returned an ID, the original ID needs to survive the full round trip and be included in the matching functionResponse. Sending only the function name is ambiguous when the same function is called more than once. Please add an end-to-end test covering response parsing, tool execution, and next-turn request construction with a real Gemini call ID.

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.

Fixed — the provider id now survives the full round trip. _build_call_id_realid_map recovers each call's real id from the stored Part, and _content_to_part adds id to the functionResponse (omitted for synthesized ids so a fabricated id is never sent back).

Added tests: test_parallel_same_function_calls_stay_distinct asserts each functionResponse carries the matching id for two same-named parallel calls, and test_chat_client_round_trips_provider_metadata exercises the parse → Content → next-turn request path.

return contents

@staticmethod
def _parse_response(data: dict[str, Any]) -> Response:

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.

This conversion drops the Part-level thoughtSignature. Because this adapter manually reconstructs conversation history, the signature cannot be returned in its original function-call Part on the next request. Gemini 2.5 returns thought signatures when thinking and function calling are enabled, and Gemini 3 requires function-call signatures; omitting them can result in MISSING_THOUGHT_SIGNATURE. Please preserve this opaque metadata through the internal content types and replay it unchanged. A two-turn round-trip test with thoughtSignature should be added.

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.

Fixed via an opaque, namespaced provider_metadata passthrough. _parse_response stores Gemini's original Part under provider_metadata["gemini"]["part"]; the shared layers only transport it through Content.additional_properties and never interpret it; _content_to_part replays the original Part verbatim, so thoughtSignature (and functionCall.id) survive unchanged. Falls back to reconstructing from neutral fields only when no original Part is available (non-Gemini / cross-turn history).

Two-turn round-trip covered by test_content_to_part_replays_original_part_verbatim and test_chat_client_round_trips_provider_metadata.

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.

2 participants