Skip to content

Commit 5dfbccc

Browse files
committed
Release v0.11.1: Normalize Live API usage metadata and improve Vertex AI compatibility
- Implement canonical `candidates_*` fields in `UsageMetadata` to unify Gemini Live (`response_*`) and Vertex Live (`candidates_*`) schemas. - Add `output_token_count/1` and `output_tokens_details/1` helpers for backend-agnostic usage tracking. - Capture and expose Vertex Live `turnCompleteReason` via `ServerContent`. - Update `RateLimiter.Manager` to reconcile usage against canonical fields, preferring `candidatesTokenCount` where available. - Refine Vertex Live model resolution to exclude native-audio-only models from text-only session candidates. - Improve test stability: - Add retry/skip logic for transient upstream 1011 errors. - Dynamically skip Vertex text-only tests if no compatible model is detected in the user's project. - Document backend schema differences and provide updated testing instructions in README and guides. - Bump version to 0.11.1.
1 parent b21ada6 commit 5dfbccc

19 files changed

Lines changed: 600 additions & 90 deletions

CHANGELOG.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.11.1] - 2026-03-27
11+
12+
### Added
13+
14+
#### Live API Schema Compatibility Helpers
15+
- **`Gemini.Types.Live.Enums.TurnCompleteReason`**: typed enum for Vertex Live `turnCompleteReason`
16+
- **`Gemini.Types.Live.ServerContent.turn_complete_reason`**: captures the server-provided turn completion reason when present
17+
- **`Gemini.Types.Live.UsageMetadata.output_token_count/1`** and **`output_tokens_details/1`**: backend-agnostic helpers for normalized Live output usage
18+
19+
### Changed
20+
- **Live usage metadata normalization**: Gemini Live `responseTokenCount` / `responseTokensDetails` and Vertex Live `candidatesTokenCount` / `candidatesTokensDetails` now populate the same canonical `candidates_*` fields in `Gemini.Types.Live.UsageMetadata`
21+
- **Backwards compatibility preserved**: `response_token_count` and `response_tokens_details` remain available as aliases for existing caller code
22+
- **Vertex text live model filtering**: native-audio-only models are no longer treated as text candidates during Vertex Live text model resolution
23+
- **Live docs and test instructions**: README and Live API guide now document the Gemini-vs-Vertex schema split and the targeted manual test commands
24+
25+
### Fixed
26+
- **Rate limiter output token accounting**: usage reconciliation now falls back to Gemini-style Live `responseTokenCount` metadata instead of assuming only `candidatesTokenCount`
27+
- **Vertex Live server content parsing**: `turnCompleteReason` is now captured and exposed through the Live type layer
28+
- **Live type coverage**: unit tests now cover Gemini-style and Vertex-style usage metadata parsing plus Vertex turn completion reasons
29+
- **Live integration test stability**: Gemini raw WebSocket setup retries/skips transient upstream `1011` internal errors, and Vertex text-only live session tests skip cleanly when the project has no text-capable Live model
30+
1031
## [0.11.0] - 2026-03-05
1132

1233
### Added
@@ -2035,7 +2056,8 @@ config :gemini_ex,
20352056
- Minimal latency overhead
20362057
- Concurrent request processing
20372058

2038-
[Unreleased]: https://github.com/nshkrdotcom/gemini_ex/compare/v0.11.0...HEAD
2059+
[Unreleased]: https://github.com/nshkrdotcom/gemini_ex/compare/v0.11.1...HEAD
2060+
[0.11.1]: https://github.com/nshkrdotcom/gemini_ex/compare/v0.11.0...v0.11.1
20392061
[0.11.0]: https://github.com/nshkrdotcom/gemini_ex/compare/v0.10.0...v0.11.0
20402062
[0.10.0]: https://github.com/nshkrdotcom/gemini_ex/compare/v0.9.1...v0.10.0
20412063
[0.9.1]: https://github.com/nshkrdotcom/gemini_ex/compare/v0.9.0...v0.9.1

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Add `gemini` to your list of dependencies in `mix.exs`:
5959
```elixir
6060
def deps do
6161
[
62-
{:gemini_ex, "~> 0.11.0"}
62+
{:gemini_ex, "~> 0.11.1"}
6363
]
6464
end
6565
```
@@ -208,6 +208,8 @@ See `guides/interactions.md` for CRUD, resumption (`last_event_id`), and backgro
208208

209209
Real-time bidirectional streaming for voice, video, and text interactions. For Gemini Live connections, `v1beta` is the default API version, while `v1alpha` is available for advanced native-audio features. Vertex Live connections use the Vertex `v1` WebSocket endpoint.
210210

211+
Gemini Live and Vertex Live do not emit identical usage metadata fields. `gemini_ex` normalizes both backends into `Gemini.Types.Live.UsageMetadata.candidates_token_count` / `candidates_tokens_details`, keeps `response_*` aliases for backwards compatibility, and exposes `Gemini.Types.Live.UsageMetadata.output_token_count/1` and `output_tokens_details/1` as backend-agnostic helpers. Vertex Live may also populate `server_content.turn_complete_reason`.
212+
211213
#### Model Resolution
212214

213215
Live API model availability varies by API key and regional rollout. `Gemini.Live.Models.resolve/1` uses the model registry plus runtime `list_models` results to select a compatible model:
@@ -1856,8 +1858,19 @@ mix test --cover
18561858

18571859
# Run integration tests (requires API key)
18581860
GEMINI_API_KEY="your_key" mix test --only integration
1861+
1862+
# Run Gemini Live session tests when GEMINI_API_KEY is already exported
1863+
mix test --only live_gemini test/gemini/live/session_live_test.exs
1864+
1865+
# Run Gemini Live feature tests when GEMINI_API_KEY is already exported
1866+
mix test --only live_gemini test/gemini/live/features_live_test.exs
1867+
1868+
# Run billed Vertex Live tests when Vertex credentials are already exported
1869+
RUN_BILLED_VERTEX_LIVE_TESTS=1 mix test --only live_vertex_ai test/gemini/live/session_vertex_live_test.exs
18591870
```
18601871

1872+
If your Vertex project exposes only native-audio Live models, the text-only Vertex session tests will skip instead of failing.
1873+
18611874
## Contributing
18621875

18631876
1. Fork the repository

guides/live_api.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,39 @@ For Vertex AI (`auth: :vertex_ai`) connections, `gemini_ex` uses the Vertex Live
102102

103103
This library abstracts the WebSocket connection details. You interact through the `Gemini.Live.Session` module.
104104

105+
### Backend Schema Differences
106+
107+
Gemini Live and Vertex Live use slightly different wire fields for response usage metadata:
108+
109+
- Gemini Live sends `responseTokenCount` and `responseTokensDetails`
110+
- Vertex Live `v1` sends `candidatesTokenCount` and `candidatesTokensDetails`
111+
- Vertex Live may also include `turnCompleteReason` on `serverContent`
112+
113+
`gemini_ex` normalizes both backends into the same Live types:
114+
115+
- `Gemini.Types.Live.UsageMetadata.candidates_token_count` and `candidates_tokens_details` are the canonical output-token fields
116+
- `response_token_count` and `response_tokens_details` are retained as backwards-compatible aliases
117+
- `Gemini.Types.Live.UsageMetadata.output_token_count/1` and `output_tokens_details/1` return the normalized output view
118+
- `Gemini.Types.Live.ServerContent.turn_complete_reason` is parsed as a `Gemini.Types.Live.Enums.TurnCompleteReason` value when present
119+
120+
Example callback code that works across both backends:
121+
122+
```elixir
123+
on_message: fn
124+
%{server_content: content, usage_metadata: usage} ->
125+
output_tokens = Gemini.Types.Live.UsageMetadata.output_token_count(usage)
126+
reason = if content, do: content.turn_complete_reason
127+
128+
IO.inspect(%{
129+
output_tokens: output_tokens,
130+
turn_complete_reason: reason
131+
})
132+
133+
_ ->
134+
:ok
135+
end
136+
```
137+
105138
### Session Configuration
106139

107140
The initial message after establishing the WebSocket connection sets the session configuration:
@@ -1051,6 +1084,25 @@ Session.close(session)
10511084

10521085
See `examples/13_live_session_resumption.exs` for a complete example.
10531086

1087+
## Testing Live Sessions
1088+
1089+
When your environment variables are already exported, run the Live integration tests directly:
1090+
1091+
```bash
1092+
# Gemini Live session coverage
1093+
mix test --only live_gemini test/gemini/live/session_live_test.exs
1094+
1095+
# Gemini Live advanced features
1096+
mix test --only live_gemini test/gemini/live/features_live_test.exs
1097+
1098+
# Vertex Live coverage (billed; requires explicit opt-in)
1099+
RUN_BILLED_VERTEX_LIVE_TESTS=1 mix test --only live_vertex_ai test/gemini/live/session_vertex_live_test.exs
1100+
```
1101+
1102+
The default test suite excludes `:live_gemini` and `:live_vertex_ai`, so these targeted commands are the intended manual verification path for real credentials.
1103+
1104+
If your Vertex project exposes only native-audio Live models, the text-only Vertex session tests will skip instead of failing. This is expected: native-audio-only Vertex models cannot satisfy `response_modalities: ["TEXT"]`.
1105+
10541106
## Further Reading
10551107

10561108
- [Example Files](https://github.com/nshkrdotcom/gemini_ex/tree/main/examples)

lib/gemini/live/models.ex

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ defmodule Gemini.Live.Models do
2222
image: :flash_2_0_exp_image_generation
2323
},
2424
vertex_ai: %{
25-
text: :flash_2_5_native_audio_preview_12_2025,
25+
text: :flash_2_0_live_001,
2626
audio: :flash_2_5_native_audio_preview_12_2025,
2727
image: :flash_2_0_preview_image_generation
2828
}
@@ -52,9 +52,7 @@ defmodule Gemini.Live.Models do
5252
},
5353
vertex_ai: %{
5454
text: [
55-
:flash_2_5_native_audio_latest,
56-
:flash_2_5_native_audio_preview_12_2025,
57-
:flash_2_5_native_audio_preview_09_2025
55+
:flash_2_0_live_001
5856
],
5957
audio: [
6058
:flash_2_5_native_audio_latest,
@@ -111,6 +109,7 @@ defmodule Gemini.Live.Models do
111109
(registry_candidates ++ legacy_candidates)
112110
|> Enum.reject(&is_nil/1)
113111
|> Enum.uniq()
112+
|> Enum.filter(&candidate_for_modality?(&1, modality, auth))
114113
end
115114

116115
@doc """
@@ -205,7 +204,9 @@ defmodule Gemini.Live.Models do
205204
end
206205

207206
defp live_candidate_for_modality?(model_name, :text, :vertex_ai) do
208-
live_like_model_name?(model_name) and not String.contains?(model_name, "tts")
207+
live_like_model_name?(model_name) and
208+
not String.contains?(model_name, "tts") and
209+
not String.contains?(model_name, "native-audio")
209210
end
210211

211212
defp live_candidate_for_modality?(model_name, :audio, _auth) do
@@ -236,6 +237,12 @@ defmodule Gemini.Live.Models do
236237

237238
defp prioritize_models(models, _modality), do: models
238239

240+
defp candidate_for_modality?(model_name, modality, auth) do
241+
model_name
242+
|> normalize_model_name()
243+
|> live_candidate_for_modality?(modality, auth)
244+
end
245+
239246
defp filter_models_by_method(models, require_method) do
240247
models
241248
|> Enum.filter(&supports_method?(&1, require_method))

lib/gemini/rate_limiter/manager.ex

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,17 +685,25 @@ defmodule Gemini.RateLimiter.Manager do
685685
Map.has_key?(response, :usage_metadata) ->
686686
cached_tokens = Map.get(response.usage_metadata, :cached_content_token_count, 0)
687687

688+
output_tokens =
689+
Map.get(response.usage_metadata, :candidates_token_count) ||
690+
Map.get(response.usage_metadata, :response_token_count, 0)
691+
688692
%{
689693
input_tokens: Map.get(response.usage_metadata, :prompt_token_count, 0) + cached_tokens,
690-
output_tokens: Map.get(response.usage_metadata, :candidates_token_count, 0)
694+
output_tokens: output_tokens
691695
}
692696

693697
Map.has_key?(response, "usageMetadata") ->
694698
cached_tokens = Map.get(response["usageMetadata"], "cachedContentTokenCount", 0)
695699

700+
output_tokens =
701+
Map.get(response["usageMetadata"], "candidatesTokenCount") ||
702+
Map.get(response["usageMetadata"], "responseTokenCount", 0)
703+
696704
%{
697705
input_tokens: Map.get(response["usageMetadata"], "promptTokenCount", 0) + cached_tokens,
698-
output_tokens: Map.get(response["usageMetadata"], "candidatesTokenCount", 0)
706+
output_tokens: output_tokens
699707
}
700708

701709
true ->

lib/gemini/types/live/enums.ex

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,39 @@ defmodule Gemini.Types.Live.Enums do
145145
def from_api(nil), do: nil
146146
def from_api(_), do: :unspecified
147147
end
148+
149+
defmodule TurnCompleteReason do
150+
@moduledoc """
151+
Reasons why a Live API turn completed.
152+
153+
## Values
154+
155+
- `:unspecified` - Unspecified completion reason
156+
- `:malformed_function_call` - The model emitted an invalid function call
157+
- `:response_rejected` - The response was rejected by the service
158+
- `:need_more_input` - The model requires more user input to continue
159+
"""
160+
161+
@type t ::
162+
:unspecified
163+
| :malformed_function_call
164+
| :response_rejected
165+
| :need_more_input
166+
| String.t()
167+
168+
@spec to_api(t()) :: String.t()
169+
def to_api(:unspecified), do: "TURN_COMPLETE_REASON_UNSPECIFIED"
170+
def to_api(:malformed_function_call), do: "MALFORMED_FUNCTION_CALL"
171+
def to_api(:response_rejected), do: "RESPONSE_REJECTED"
172+
def to_api(:need_more_input), do: "NEED_MORE_INPUT"
173+
def to_api(value) when is_binary(value), do: value
174+
175+
@spec from_api(String.t() | nil) :: t() | nil
176+
def from_api("TURN_COMPLETE_REASON_UNSPECIFIED"), do: :unspecified
177+
def from_api("MALFORMED_FUNCTION_CALL"), do: :malformed_function_call
178+
def from_api("RESPONSE_REJECTED"), do: :response_rejected
179+
def from_api("NEED_MORE_INPUT"), do: :need_more_input
180+
def from_api(nil), do: nil
181+
def from_api(value) when is_binary(value), do: value
182+
end
148183
end

lib/gemini/types/live/server_content.ex

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ defmodule Gemini.Types.Live.ServerContent do
1616
- `input_transcription` - Transcription of input audio
1717
- `output_transcription` - Transcription of model's audio output
1818
- `url_context_metadata` - Metadata from URL context retrieval
19+
- `turn_complete_reason` - Reason why the turn completed on Vertex Live
1920
2021
## Example
2122
@@ -25,6 +26,7 @@ defmodule Gemini.Types.Live.ServerContent do
2526
}
2627
"""
2728

29+
alias Gemini.Types.Live.Enums.TurnCompleteReason
2830
alias Gemini.Types.Live.{GroundingMetadata, Transcription}
2931

3032
@type content :: %{
@@ -44,7 +46,8 @@ defmodule Gemini.Types.Live.ServerContent do
4446
grounding_metadata: GroundingMetadata.t() | nil,
4547
input_transcription: Transcription.t() | nil,
4648
output_transcription: Transcription.t() | nil,
47-
url_context_metadata: url_context_metadata() | nil
49+
url_context_metadata: url_context_metadata() | nil,
50+
turn_complete_reason: TurnCompleteReason.t() | nil
4851
}
4952

5053
defstruct [
@@ -55,7 +58,8 @@ defmodule Gemini.Types.Live.ServerContent do
5558
:grounding_metadata,
5659
:input_transcription,
5760
:output_transcription,
58-
:url_context_metadata
61+
:url_context_metadata,
62+
:turn_complete_reason
5963
]
6064

6165
@doc """
@@ -71,7 +75,8 @@ defmodule Gemini.Types.Live.ServerContent do
7175
grounding_metadata: Keyword.get(opts, :grounding_metadata),
7276
input_transcription: Keyword.get(opts, :input_transcription),
7377
output_transcription: Keyword.get(opts, :output_transcription),
74-
url_context_metadata: Keyword.get(opts, :url_context_metadata)
78+
url_context_metadata: Keyword.get(opts, :url_context_metadata),
79+
turn_complete_reason: Keyword.get(opts, :turn_complete_reason)
7580
}
7681
end
7782

@@ -91,6 +96,7 @@ defmodule Gemini.Types.Live.ServerContent do
9196
|> maybe_put("inputTranscription", Transcription.to_api(value.input_transcription))
9297
|> maybe_put("outputTranscription", Transcription.to_api(value.output_transcription))
9398
|> maybe_put("urlContextMetadata", convert_url_context_to_api(value.url_context_metadata))
99+
|> maybe_put("turnCompleteReason", normalize_turn_complete_reason(value.turn_complete_reason))
94100
end
95101

96102
@doc """
@@ -115,7 +121,10 @@ defmodule Gemini.Types.Live.ServerContent do
115121
(data["outputTranscription"] || data["output_transcription"])
116122
|> Transcription.from_api(),
117123
url_context_metadata:
118-
parse_url_context(data["urlContextMetadata"] || data["url_context_metadata"])
124+
parse_url_context(data["urlContextMetadata"] || data["url_context_metadata"]),
125+
turn_complete_reason:
126+
(data["turnCompleteReason"] || data["turn_complete_reason"])
127+
|> TurnCompleteReason.from_api()
119128
}
120129
end
121130

@@ -197,6 +206,13 @@ defmodule Gemini.Types.Live.ServerContent do
197206
}
198207
end
199208

209+
defp normalize_turn_complete_reason(nil), do: nil
210+
211+
defp normalize_turn_complete_reason(value) when is_atom(value),
212+
do: TurnCompleteReason.to_api(value)
213+
214+
defp normalize_turn_complete_reason(value), do: value
215+
200216
defp maybe_put(map, _key, nil), do: map
201217
defp maybe_put(map, key, value), do: Map.put(map, key, value)
202218
end

0 commit comments

Comments
 (0)