Skip to content

Python: [Bug]: Gemini chat client drops unmapped finish reasons and their usage/token accounting #7836

Description

Description

#7105 gave chat clients a fallback so an unmapped provider finish reason value passes through as the raw string instead of vanishing to None. It touched ag, bedrock, claude, core, github_copilot, ollama, and openai. It did not touch gemini.

RawGeminiChatClient._map_finish_reason in python/packages/gemini/agent_framework_gemini/_chat_client.py still has no fallback:

def _map_finish_reason(self, reason: str | None) -> FinishReasonLiteral | None:
    if not reason:
        return None
    return _FINISH_REASON_MAP.get(reason)

_FINISH_REASON_MAP covers 13 of the 18 members of google.genai.types.FinishReason in the package's own pinned dependency range (google-genai>=1.69.0,<3.0.0). Missing: OTHER, TOO_MANY_TOOL_CALLS, NO_IMAGE, IMAGE_OTHER (plus FINISH_REASON_UNSPECIFIED, which is a legitimate absent case and should keep mapping to None).

This cascades into two more silent losses. In _process_chunk:

if finish_reason and (usage := self._parse_usage(chunk.usage_metadata)):

Usage is attached to a streamed chunk only when finish_reason is truthy, so an unmapped reason drops the finish reason and the whole turn's token/billing accounting. ChatTelemetryLayer in observability.py skips recording the terminal OTel span state the same way when finish_reason is falsy.

Concretely: a Gemini call in an agentic tool loop trips Gemini's own tool-call-count guardrail and returns finish_reason=TOO_MANY_TOOL_CALLS. The caller sees finish_reason=None, no usage, and has no way to tell the run stopped abnormally instead of completing normally.

Code Sample

from google.genai import types
from agent_framework_gemini._chat_client import RawGeminiChatClient

chunk = types.GenerateContentResponse(
    candidates=[
        types.Candidate(
            content=types.Content(role="model", parts=[]),
            finish_reason=types.FinishReason.TOO_MANY_TOOL_CALLS,
        )
    ],
    model_version="gemini-2.5-flash",
    usage_metadata=types.GenerateContentResponseUsageMetadata(
        prompt_token_count=1200, candidates_token_count=340, total_token_count=1540,
    ),
)

update = RawGeminiChatClient._process_chunk(object.__new__(RawGeminiChatClient), chunk)
print(update.finish_reason)  # None -- should be "TOO_MANY_TOOL_CALLS"
print(update.contents)       # [] -- usage silently dropped

Error Messages / Stack Traces

No exception is raised; the failure is silent (finish reason and usage both become absent).

Package Versions

agent-framework-gemini: 1.0.0b260813 (current main), google-genai: 2.17.0

Python Version

Python 3.13

Additional Context

Bedrock's version of the same method is the canonical shape after #7105:

def _map_finish_reason(self, reason: str | None) -> str | None:
    if not reason:
        return None
    return FINISH_REASON_MAP.get(reason.lower(), reason)

I have a minimal fix ready (mirrors the ollama/openai pattern of wrapping the raw fallback in FinishReason(...) so it type-checks under this package's strict Pyright config, since bedrock's file carries a blanket # type: ignore that gemini's does not) plus new unit tests covering the four newly-mapped values, the usage-cascade case, and regressions for the 13 already-mapped values and the None/FINISH_REASON_UNSPECIFIED absent case. Opening a PR for it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    pythonUsage: [Issues, PRs], Target: PythontriageUsage: [Issues], Target: All issues that still need to be triaged

    Type

    No type

    Projects

    Status
    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions