Skip to content

Populate normalized finish_reason from Google finishReason (+ fix streaming drop)#67

Merged
DeanSingh merged 3 commits into
mainfrom
finish-reason
Jun 11, 2026
Merged

Populate normalized finish_reason from Google finishReason (+ fix streaming drop)#67
DeanSingh merged 3 commits into
mainfrom
finish-reason

Conversation

@DeanSingh

@DeanSingh DeanSingh commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Maps the Gemini candidate finishReason onto a normalized OmniAI::Chat::FinishReason value object on Response#finish_reason (and per-choice) — and fixes a real, pre-existing streaming bug that silently dropped it.

Mapping (table lives next to the deserializer)

raw finishReason reason
STOP :stop
MAX_TOKENS :length
SAFETY, RECITATION, LANGUAGE, BLOCKLIST, PROHIBITED_CONTENT, SPII, IMAGE_SAFETY :filter
OTHER, MALFORMED_FUNCTION_CALL, anything unrecognized :other

The whole content-policy family maps to :filter (so a consumer branching on :filter catches every policy termination), while the verbatim token is preserved as finish_reason.value — so RECITATION vs SAFETY granularity is never lost. Values verified against the FinishReason enum.

Streaming fix — a REAL pre-existing bug (independent of this feature)

In real Gemini streams, finishReason arrives on the terminal chunk, after content has streamed. But Stream#merge_candidate! only merged content.parts into an already-seen candidate and dropped top-level keys, and process_candidate! skipped candidates with no content entirely. Net effect: on a streamed MAX_TOKENS / SAFETY cutoff, finishReason was silently lost — the exact failures a blank-completion detector exists to catch. (Two pre-existing specs even encoded the dropped behavior as "correct.")

Verified merge semantics after the fix:

  • terminal-chunk top-level keys winfinishReason arriving on a later chunk is merged onto the existing candidate (not discarded);
  • dig-guarded part iteration — no spurious deltas yielded for content-less chunks;
  • merge_part! tolerates a content-less first chunkcontent/parts are initialized on demand, so any chunk ordering assembles correctly.

Two pre-existing specs corrected to the preserving behavior, plus a new regression spec for the realistic ordering (content → terminal finishReason-only chunk).

Tests

TDD; non-streaming + streaming + regression + a mapping-table spec (reason + verbatim-value preservation). 284 examples, 0 failures; rubocop clean.

Dependency / release ordering

Bumps the omniai floor to ~> 3.7; releases as omniai-google 3.10.0.

⚠️ Depends on omniai 3.7.0 (ksylvest/omniai#289). Merge + publish that first; CI here goes green once 3.7.0 is on RubyGems.

@DeanSingh DeanSingh changed the title Populate finish_reason from Google finishReason (+ fix streaming drop) Populate normalized finish_reason from Google finishReason (+ fix streaming drop) Jun 10, 2026
@DeanSingh DeanSingh force-pushed the finish-reason branch 2 times, most recently from 7de2a47 to fb69c60 Compare June 11, 2026 00:18
…eaming drop)

Map the candidate-level finishReason onto the normalized
OmniAI::Chat::FinishReason symbols via a mapping table next to the deserializer:
  STOP                                              -> :stop
  MAX_TOKENS                                        -> :length
  SAFETY/RECITATION/LANGUAGE/BLOCKLIST/
    PROHIBITED_CONTENT/SPII/IMAGE_SAFETY            -> :filter
The whole content-policy family maps to :filter; unrecognized values (OTHER,
MALFORMED_FUNCTION_CALL, ...) -> :other. The raw finishReason stays available
via response.data.

Also fixes a streaming bug: finishReason arrives on the terminal chunk after
content has streamed, but Stream#merge_candidate! only merged content.parts
into an already-seen candidate and dropped top-level keys -- so finishReason
(and thus :length on a MAX_TOKENS cutoff) was silently lost. The merge now
preserves top-level candidate keys and no longer skips content-less terminal
chunks.

Requires omniai ~> 3.7. Releases as 3.10.0.
@DeanSingh DeanSingh force-pushed the finish-reason branch 2 times, most recently from f6e7ac1 to 6c415ad Compare June 11, 2026 04:25
omniai 3.7 widened its http constraint to >= 5, < 7. Make the google gem work
under http 6 (while staying compatible with http 5):

- transcribe: http 6's .timeout no longer accepts a per-operation hash via
  keyword-splat, and rejects nil per-operation values. Pass the options hash
  positionally, and return :null (the http no-timeout sentinel, valid in both
  5 and 6) from #http_timeout_options when no timeout is configured.
- client_spec: HTTP.persistent returns HTTP::Session on http 6 (HTTP::Client on
  http 5); assert the request contract instead of the version-specific class.

Suite green under both http 6.0.3 and http 5.3.1. Lock resolves omniai 3.7.0.
@DeanSingh DeanSingh merged commit ee826c4 into main Jun 11, 2026
7 checks passed
@DeanSingh DeanSingh deleted the finish-reason branch June 11, 2026 04:30
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