Python: fix Gemini finish reason fallback and usage-attach cascade - #7837
Python: fix Gemini finish reason fallback and usage-attach cascade#7837Jeremy Schoemaker (shoemoney) wants to merge 1 commit into
Conversation
microsoft#7105 added a fallback so unmapped provider finish reasons pass through as the raw string instead of being dropped to None. It covered ag, bedrock, claude, core, github_copilot, ollama, and openai, but not gemini. _FINISH_REASON_MAP covers 13 of the 18 members of google.genai.types.FinishReason in the package's pinned dependency range. OTHER, TOO_MANY_TOOL_CALLS, NO_IMAGE, and IMAGE_OTHER fell through to None. FINISH_REASON_UNSPECIFIED still correctly maps to None. _process_chunk only attaches usage to a streamed chunk when finish_reason is truthy, so an unmapped reason silently dropped both the finish reason and the whole turn's usage/token accounting. Mirrors the ollama/openai pattern of wrapping the raw fallback in FinishReason(...) rather than bedrock's plain str return, since bedrock's file carries a blanket type: ignore that gemini's does not. Fixes microsoft#7836
There was a problem hiding this comment.
Pull request overview
This PR fixes a bug in the Gemini Python chat client where unmapped provider finish reasons were silently dropped to None. #7105 added a raw-string fallback to the other chat clients (ag, bedrock, claude, core, github_copilot, ollama, openai) but missed gemini. Because streamed-chunk usage is attached only when finish_reason is truthy, an unmapped reason (e.g. TOO_MANY_TOOL_CALLS) dropped both the finish reason and the turn's token/billing accounting. The fix mirrors the established ollama/openai fallback pattern, wrapping the raw reason as FinishReason(reason) so it type-checks under this package's strict Pyright config.
Changes:
_map_finish_reasonnow falls back toFinishReason(reason)for values absent from_FINISH_REASON_MAP, whileFINISH_REASON_UNSPECIFIEDand absent reasons still map toNone; return type widened toFinishReasonLiteral | FinishReason | None.- Added the
FinishReasonimport and updated the docstring to explain the fallback behavior. - Expanded finish-reason mapping tests and added a regression test confirming usage is still attached to the final streamed chunk under an unmapped reason.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
python/packages/gemini/agent_framework_gemini/_chat_client.py |
Adds raw-string fallback in _map_finish_reason, imports FinishReason, widens the return type, and updates the docstring. |
python/packages/gemini/tests/test_gemini_client.py |
Expands parametrized mapping cases (mapped, unmapped-passthrough, None/UNSPECIFIED) and adds a streamed-chunk usage-cascade regression test. |
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
Motivation & Context
#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 touchedag,bedrock,claude,core,github_copilot,ollama, andopenai. It did not touchgemini.Bedrock's version of the same method after #7105:
Gemini's still has no fallback:
_FINISH_REASON_MAPcovers 13 of the 18 members ofgoogle.genai.types.FinishReasonin 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(plusFINISH_REASON_UNSPECIFIED, which is a legitimate absent case and correctly staysNone).The bug cascades. In
_process_chunk:Usage is attached to a streamed chunk only when
finish_reasonis truthy, so an unmapped reason drops the finish reason and the whole turn's token/billing accounting.ChatTelemetryLayerinobservability.pyskips recording the terminal span state the same way.Concrete scenario: a Gemini call inside an agentic tool loop trips Gemini's own tool-call-count guardrail and returns
finish_reason=TOO_MANY_TOOL_CALLS. The caller seesfinish_reason=None, no usage, and no way to tell the run stopped abnormally instead of completing normally.Description & Review Guide
_map_finish_reasonnow falls back to the raw reason string, wrapped asFinishReason(reason), instead ofNone.FINISH_REASON_UNSPECIFIEDand an absent reason still map toNone.FinishReasonLiteral | NonetoFinishReasonLiteral | FinishReason | None. This is not the same pattern bedrock uses (str | Nonewith no wrapping) — bedrock's file carries a blanket# type: ignoreat the top of the module, gemini's does not, so a barestrfails this package's strict Pyright config.FinishReason(reason)is the same constructollamaandopenaiused for the same fallback in Python: Normalize chat finish reasons #7105.OTHER,TOO_MANY_TOOL_CALLS,NO_IMAGE,IMAGE_OTHER) is now surfaced to the caller instead of silently disappearing, and the final streamed chunk's usage/token accounting is no longer dropped alongside it.FINISH_REASON_UNSPECIFIEDis the only value that should keep mapping toNone, or whether other values should be excluded from the fallback too.Related Issue
Fixes #7836
Testing
Ran the gemini package's unit test suite only (not the full monorepo suite):
GREEN (with the fix): 157 passed, 8 deselected (integration tests, no credentials configured).
Reverted only the source change (kept the new/updated tests) to confirm RED:
Restored the fix, reran, back to GREEN (157 passed).
Also ran
pyrightandruff check/ruff format --checkagainst the changed files only; all clean. Did not run the .NET suite, the full Python monorepo test suite, or integration tests (no Gemini/Vertex credentials in this environment).Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.