├── test
├── fixtures
│ ├── test_document.txt
│ ├── test_image.png
│ └── multimodal
│ │ ├── test_image_1x1.jpg
│ │ ├── test_image_1x1.png
│ │ ├── test_image_2x2_colored.png
│ │ └── README.md
├── test_helper.exs
├── gemini
│ ├── types
│ │ ├── response
│ │ │ ├── modality_token_count_test.exs
│ │ │ ├── safety_rating_test.exs
│ │ │ ├── traffic_type_test.exs
│ │ │ ├── usage_metadata_test.exs
│ │ │ └── batch_state_test.exs
│ │ ├── common
│ │ │ ├── modality_test.exs
│ │ │ ├── file_data_test.exs
│ │ │ ├── media_resolution_test.exs
│ │ │ ├── speech_config_test.exs
│ │ │ ├── function_response_test.exs
│ │ │ └── part_new_fields_test.exs
│ │ ├── generation_config_new_fields_test.exs
│ │ └── tool_serialization_test.exs
│ ├── auth_test.exs
│ ├── auth
│ │ └── gemini_strategy_test.exs
│ ├── apis
│ │ ├── coordinator_response_parsing_test.exs
│ │ ├── coordinator_model_path_building_test.exs
│ │ ├── coordinator_model_with_endpoint_test.exs
│ │ └── coordinator_generation_config_new_fields_test.exs
│ └── telemetry_test.exs
├── support
│ └── auth_helpers.ex
├── coordinator_integration_test.exs
├── live_api
│ └── gemini3_image_preview_live_test.exs
├── gemini_test.exs
└── live_api_test.exs
├── .formatter.exs
├── config
├── dev.exs
├── test.exs
├── runtime.exs
└── config.exs
├── lib
└── gemini
│ ├── application.ex
│ ├── types
│ ├── cached_content_usage_metadata.ex
│ ├── response
│ │ ├── traffic_type.ex
│ │ ├── modality_token_count.ex
│ │ ├── embed_content_response.ex
│ │ ├── batch_embed_contents_response.ex
│ │ ├── batch_state.ex
│ │ ├── embed_content_batch_output.ex
│ │ ├── inlined_embed_content_response.ex
│ │ └── inlined_embed_content_responses.ex
│ ├── common
│ │ ├── modality.ex
│ │ ├── file_data.ex
│ │ ├── media_resolution.ex
│ │ ├── blob.ex
│ │ ├── safety_setting.ex
│ │ ├── function_response.ex
│ │ ├── content.ex
│ │ └── speech_config.ex
│ ├── request
│ │ ├── inlined_embed_content_requests.ex
│ │ ├── inlined_embed_content_request.ex
│ │ ├── batch_embed_contents_request.ex
│ │ ├── embed_content_batch.ex
│ │ └── input_embed_content_config.ex
│ └── interactions
│ │ └── agent_config.ex
│ ├── supervisor.ex
│ ├── auth
│ └── gemini_strategy.ex
│ ├── client.ex
│ ├── tools.ex
│ ├── error.ex
│ ├── utils
│ └── resource_names.ex
│ └── auth.ex
├── docs
├── 20251203
│ └── gemini_rate_limits
│ │ └── adrs
│ │ ├── ADR-0004-telemetry-and-config.md
│ │ ├── ADR-0002-retries-and-backoff.md
│ │ ├── ADR-0003-concurrency-and-budgeting.md
│ │ └── ADR-0001-rate-limit-manager.md
├── 20251204
│ ├── context-caching-enhancement
│ │ ├── EXECUTION_PLAN.md
│ │ └── IMPLEMENTATION_PLAN_QA.md
│ └── proactive-rate-limiting
│ │ └── adrs
│ │ └── README.md
├── 20251205
│ └── over_budget_retry_fix
│ │ └── analysis.md
├── 20251206
│ └── gap_analysis
│ │ └── README.md
├── issues
│ ├── pr-10.json
│ ├── issue-09.json
│ ├── issue-11.json
│ └── ISSUE_SUMMARY.md
├── guides
│ └── README.md
├── INTEGRATION_NOTES.md
├── gemini_api_reference_2025_10_07
│ └── README.md
└── technical
│ └── initiatives
│ ├── README.md
│ └── INITIATIVE_001_SUMMARY.md
├── .hexignore
├── .gitignore
├── LICENSE
├── oldDocs
└── docs
│ └── spec
│ ├── GEMINI-DOCS-01-QUICKSTART.md
│ ├── GEMINI-DOCS-03-LIBRARIES.md
│ ├── GEMINI-DOCS-02-API-KEYS.md
│ └── GEMINI-DOCS-22-GROUNDING-WITH-GOOGLE-SEARCH_USE-GOOGLE-SEARCH-SUGGESTIONS.md
├── assets
└── logo.svg
├── examples
├── structured_outputs_basic.exs
├── simple_embedding.exs
├── structured_outputs_standalone.exs
├── simple_live_test.exs
├── tool_calling_demo.exs
├── streaming_demo.exs
└── run_all.sh
├── repro_concurrency_gate.exs
└── .kiro
└── specs
└── generation-config-bug-fix
└── requirements.md
/test/fixtures/test_document.txt:
--------------------------------------------------------------------------------
1 | This is a test file for Gemini API file upload testing.
2 |
--------------------------------------------------------------------------------
/test/fixtures/test_image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nshkrdotcom/gemini_ex/HEAD/test/fixtures/test_image.png
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | # Used by "mix format"
2 | [
3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/test/fixtures/multimodal/test_image_1x1.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nshkrdotcom/gemini_ex/HEAD/test/fixtures/multimodal/test_image_1x1.jpg
--------------------------------------------------------------------------------
/test/fixtures/multimodal/test_image_1x1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nshkrdotcom/gemini_ex/HEAD/test/fixtures/multimodal/test_image_1x1.png
--------------------------------------------------------------------------------
/test/fixtures/multimodal/test_image_2x2_colored.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nshkrdotcom/gemini_ex/HEAD/test/fixtures/multimodal/test_image_2x2_colored.png
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start(exclude: [:live_api, :slow])
2 |
3 | # Ensure token cache table exists before async tests run
4 | Gemini.Auth.TokenCache.init()
5 |
--------------------------------------------------------------------------------
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Development configuration
4 | config :gemini_ex,
5 | # Enable debug logging in development
6 | log_level: :debug
7 |
8 | # You can set your API key here for development, or use environment variables
9 | # config :gemini_ex, api_key: "your_development_api_key"
10 |
--------------------------------------------------------------------------------
/lib/gemini/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Application do
2 | @moduledoc false
3 |
4 | use Application
5 |
6 | @impl true
7 | def start(_type, _args) do
8 | children = [
9 | {Gemini.Supervisor, []}
10 | ]
11 |
12 | opts = [strategy: :one_for_one, name: Gemini.Application.Supervisor]
13 | Supervisor.start_link(children, opts)
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Suppress all console log output during tests (only show test dots)
4 | # But keep Logger enabled so ExUnit.CaptureLog can capture logs for assertions
5 | config :logger, :console, level: :none
6 |
7 | # Test configuration
8 | config :gemini_ex,
9 | # Disable telemetry in tests
10 | telemetry_enabled: false,
11 |
12 | # Use mock endpoints in tests
13 | mock_mode: true
14 |
--------------------------------------------------------------------------------
/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Runtime configuration (production)
4 | if config_env() == :prod do
5 | # Configure from environment variables in production
6 | if api_key = System.get_env("GEMINI_API_KEY") do
7 | config :gemini_ex, api_key: api_key
8 | end
9 |
10 | if project_id = System.get_env("VERTEX_PROJECT_ID") do
11 | config :gemini_ex, vertex_project_id: project_id
12 | end
13 |
14 | if location = System.get_env("VERTEX_LOCATION") do
15 | config :gemini_ex, vertex_location: location
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/docs/issues/pr-10.json:
--------------------------------------------------------------------------------
1 | {"author":{"id":"MDQ6VXNlcjgwNTY4NTk3","is_bot":false,"login":"yosuaw","name":"Yosua Wijaya"},"body":"Supporting Thinking Budget Config for Gemini.APIs.Coordinator (#9)","comments":[],"createdAt":"2025-09-01T13:52:42Z","files":[{"path":"lib/gemini/apis/coordinator.ex","additions":22,"deletions":0},{"path":"lib/gemini/types/common/generation_config.ex","additions":24,"deletions":0}],"labels":[],"number":10,"reviews":[],"state":"OPEN","title":"Support Thinking Budget","updatedAt":"2025-09-01T13:52:42Z","url":"https://github.com/nshkrdotcom/gemini_ex/pull/10"}
2 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Configure the Gemini client
4 | config :gemini_ex,
5 | # Default model is auto-detected based on authentication:
6 | # - Gemini API (GEMINI_API_KEY): "gemini-flash-lite-latest"
7 | # - Vertex AI (VERTEX_PROJECT_ID): "gemini-2.0-flash-lite"
8 | # Uncomment to override: default_model: "your-model-name",
9 |
10 | # HTTP timeout in milliseconds
11 | timeout: 120_000,
12 |
13 | # Enable telemetry events
14 | telemetry_enabled: true
15 |
16 | # Import environment specific config
17 | import_config "#{config_env()}.exs"
18 |
--------------------------------------------------------------------------------
/lib/gemini/types/cached_content_usage_metadata.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.CachedContentUsageMetadata do
2 | @moduledoc """
3 | Metadata describing cached content usage.
4 | """
5 |
6 | use TypedStruct
7 |
8 | @derive Jason.Encoder
9 | typedstruct do
10 | field(:total_token_count, integer() | nil)
11 | field(:cached_content_token_count, integer() | nil)
12 | field(:audio_duration_seconds, integer() | nil)
13 | field(:image_count, integer() | nil)
14 | field(:text_count, integer() | nil)
15 | field(:video_duration_seconds, integer() | nil)
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/.hexignore:
--------------------------------------------------------------------------------
1 | # Hex.pm configuration
2 | # Files to exclude from the package
3 |
4 | # Development files
5 | .DS_Store
6 | .elixir_ls/
7 | .vscode/
8 | _build/
9 | deps/
10 | .git/
11 | .gitignore
12 |
13 | # Documentation build artifacts
14 | doc/
15 | docs/
16 |
17 | # Test files
18 | test/
19 |
20 | # Development scripts
21 | *.sh
22 |
23 | # Configuration files
24 | config/
25 |
26 | # Planning and development docs
27 | PLAN*.md
28 | IDEAS*.md
29 | CLAUDE.md
30 | *.xml
31 |
32 | # Old documentation
33 | oldDocs/
34 |
35 | # Examples directory (should be included in docs instead)
36 | examples/
37 |
38 | # Temporary directories
39 | gemini_unified/
40 |
41 | repomix*.xml
42 | *.tar
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where third-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # If the VM crashes, it generates a dump, let's ignore it too.
14 | erl_crash.dump
15 |
16 | # Also ignore archive artifacts (built via "mix archive.build").
17 | *.ez
18 |
19 | # Ignore package tarball (built via "mix hex.build").
20 | gemini-*.tar
21 |
22 | # Temporary files, for example, from tests.
23 | /tmp/
24 |
25 | repomix*.xml
26 | ex_llm/
27 |
28 | *.tar
29 |
30 | repomix*.xml
31 | /python-genai/
32 |
33 | /ALTAR/
34 |
35 | # Generated content from examples (images, etc.)
36 | /generated/
37 | /debug/
38 |
--------------------------------------------------------------------------------
/test/gemini/types/response/modality_token_count_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.ModalityTokenCountTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.Response.ModalityTokenCount
5 |
6 | describe "from_api/1" do
7 | test "parses modality and token_count" do
8 | payload = %{"modality" => "TEXT", "tokenCount" => 42}
9 |
10 | assert %ModalityTokenCount{modality: :text, token_count: 42} =
11 | ModalityTokenCount.from_api(payload)
12 | end
13 |
14 | test "returns nil for nil input" do
15 | assert ModalityTokenCount.from_api(nil) == nil
16 | end
17 |
18 | test "falls back to unspecified modality for unknown values" do
19 | payload = %{"modality" => "UNKNOWN_MODALITY", "tokenCount" => 7}
20 |
21 | assert %ModalityTokenCount{modality: :modality_unspecified, token_count: 7} =
22 | ModalityTokenCount.from_api(payload)
23 | end
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/docs/guides/README.md:
--------------------------------------------------------------------------------
1 | # GeminiEx Guides
2 |
3 | This folder contains the primary guides for running GeminiEx in production:
4 |
5 | - `interactions.md` – stateful interactions (CRUD), streaming, and resumption
6 | - `rate_limiting.md` – atomic token reservations, jittered retry windows, and concurrency gating defaults
7 | - `structured_outputs.md` – structured JSON responses
8 | - `files.md`, `batches.md`, `operations.md` – working with Files, Batch jobs, and long-running operations
9 |
10 | Rate limiting highlights (v0.7.1):
11 | - Atomic budget reservation with safety multipliers and reconciliation after responses
12 | - Shared retry windows with jittered release (`retry_window_set/hit/release` telemetry)
13 | - Telemetry for budget reservations (`budget_reserved`/`budget_rejected`) and concurrency gate hardening
14 |
15 | For model selection, use `Gemini.Config.model_for_use_case/2` to resolve the built-in use-case aliases (`:cache_context`, `:report_section`, `:fast_path`) to the registered model strings.
16 |
--------------------------------------------------------------------------------
/test/gemini/types/common/modality_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.ModalityTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.Modality
5 |
6 | describe "from_api/1" do
7 | test "maps known modalities" do
8 | assert Modality.from_api("TEXT") == :text
9 | assert Modality.from_api("IMAGE") == :image
10 | assert Modality.from_api("AUDIO") == :audio
11 | end
12 |
13 | test "returns unspecified for unknown or nil" do
14 | assert Modality.from_api("FOO") == :modality_unspecified
15 | assert Modality.from_api(nil) == nil
16 | end
17 | end
18 |
19 | describe "to_api/1" do
20 | test "converts atoms to API strings" do
21 | assert Modality.to_api(:text) == "TEXT"
22 | assert Modality.to_api(:image) == "IMAGE"
23 | assert Modality.to_api(:audio) == "AUDIO"
24 | end
25 |
26 | test "defaults to unspecified string for unknown atoms" do
27 | assert Modality.to_api(:unknown) == "MODALITY_UNSPECIFIED"
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/test/gemini/types/response/safety_rating_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.SafetyRatingTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.Response.SafetyRating
5 |
6 | describe "from_api/1" do
7 | test "parses new score fields" do
8 | payload = %{
9 | "category" => "HARM_CATEGORY_HATE_SPEECH",
10 | "probability" => "HIGH",
11 | "probabilityScore" => 0.9,
12 | "severity" => "harm_severity_high",
13 | "severityScore" => 0.8,
14 | "blocked" => true
15 | }
16 |
17 | rating = SafetyRating.from_api(payload)
18 |
19 | assert rating.category == "HARM_CATEGORY_HATE_SPEECH"
20 | assert rating.probability == "HIGH"
21 | assert rating.probability_score == 0.9
22 | assert rating.severity == "harm_severity_high"
23 | assert rating.severity_score == 0.8
24 | assert rating.blocked == true
25 | end
26 |
27 | test "returns nil for nil input" do
28 | assert SafetyRating.from_api(nil) == nil
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/gemini/types/response/traffic_type.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.TrafficType do
2 | @moduledoc """
3 | Traffic type for API requests (billing classification).
4 | """
5 |
6 | @type t :: :traffic_type_unspecified | :on_demand | :provisioned_throughput
7 |
8 | @doc """
9 | Parse traffic type from API string.
10 | """
11 | @spec from_api(String.t() | nil) :: t() | nil
12 | def from_api(nil), do: nil
13 | def from_api("ON_DEMAND"), do: :on_demand
14 | def from_api("PROVISIONED_THROUGHPUT"), do: :provisioned_throughput
15 | def from_api("TRAFFIC_TYPE_UNSPECIFIED"), do: :traffic_type_unspecified
16 | def from_api(_), do: :traffic_type_unspecified
17 |
18 | @doc """
19 | Convert traffic type atom to API string.
20 | """
21 | @spec to_api(t() | atom() | nil) :: String.t() | nil
22 | def to_api(nil), do: nil
23 | def to_api(:on_demand), do: "ON_DEMAND"
24 | def to_api(:provisioned_throughput), do: "PROVISIONED_THROUGHPUT"
25 | def to_api(:traffic_type_unspecified), do: "TRAFFIC_TYPE_UNSPECIFIED"
26 | def to_api(_), do: "TRAFFIC_TYPE_UNSPECIFIED"
27 | end
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 nshkrdotcom
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/test/gemini/types/response/traffic_type_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.TrafficTypeTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.Response.TrafficType
5 |
6 | describe "from_api/1" do
7 | test "maps known values" do
8 | assert TrafficType.from_api("ON_DEMAND") == :on_demand
9 | assert TrafficType.from_api("PROVISIONED_THROUGHPUT") == :provisioned_throughput
10 | end
11 |
12 | test "returns unspecified for unknown" do
13 | assert TrafficType.from_api("SOMETHING_ELSE") == :traffic_type_unspecified
14 | assert TrafficType.from_api(nil) == nil
15 | end
16 | end
17 |
18 | describe "to_api/1" do
19 | test "converts atoms to API strings" do
20 | assert TrafficType.to_api(:on_demand) == "ON_DEMAND"
21 | assert TrafficType.to_api(:provisioned_throughput) == "PROVISIONED_THROUGHPUT"
22 | assert TrafficType.to_api(:traffic_type_unspecified) == "TRAFFIC_TYPE_UNSPECIFIED"
23 | end
24 |
25 | test "defaults to unspecified for unknown atoms" do
26 | assert TrafficType.to_api(:unexpected) == "TRAFFIC_TYPE_UNSPECIFIED"
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/gemini/types/common/modality.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Modality do
2 | @moduledoc """
3 | Response modality types for multimodal generation.
4 | """
5 |
6 | @type t :: :modality_unspecified | :text | :image | :audio
7 |
8 | @api_values %{
9 | "TEXT" => :text,
10 | "IMAGE" => :image,
11 | "AUDIO" => :audio,
12 | "MODALITY_UNSPECIFIED" => :modality_unspecified
13 | }
14 |
15 | @reverse_api_values %{
16 | text: "TEXT",
17 | image: "IMAGE",
18 | audio: "AUDIO",
19 | modality_unspecified: "MODALITY_UNSPECIFIED"
20 | }
21 |
22 | @doc """
23 | Convert API modality string to atom.
24 | """
25 | @spec from_api(String.t() | nil) :: t() | nil
26 | def from_api(nil), do: nil
27 | def from_api(value) when is_atom(value), do: value
28 |
29 | def from_api(value) when is_binary(value),
30 | do: Map.get(@api_values, value, :modality_unspecified)
31 |
32 | @doc """
33 | Convert modality atom to API string.
34 | """
35 | @spec to_api(t() | nil) :: String.t() | nil
36 | def to_api(nil), do: nil
37 | def to_api(value) when is_binary(value), do: value
38 | def to_api(value), do: Map.get(@reverse_api_values, value, "MODALITY_UNSPECIFIED")
39 | end
40 |
--------------------------------------------------------------------------------
/test/gemini/types/common/file_data_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.FileDataTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.FileData
5 |
6 | describe "from_api/1" do
7 | test "parses camelCase keys" do
8 | payload = %{
9 | "fileUri" => "gs://bucket/file.txt",
10 | "mimeType" => "text/plain",
11 | "displayName" => "file.txt"
12 | }
13 |
14 | file_data = FileData.from_api(payload)
15 |
16 | assert file_data.file_uri == "gs://bucket/file.txt"
17 | assert file_data.mime_type == "text/plain"
18 | assert file_data.display_name == "file.txt"
19 | end
20 |
21 | test "returns nil for nil input" do
22 | assert FileData.from_api(nil) == nil
23 | end
24 | end
25 |
26 | describe "to_api/1" do
27 | test "converts to camelCase keys" do
28 | struct = %FileData{
29 | file_uri: "gs://bucket/asset.mp3",
30 | mime_type: "audio/mpeg",
31 | display_name: "asset.mp3"
32 | }
33 |
34 | assert FileData.to_api(struct) == %{
35 | "fileUri" => "gs://bucket/asset.mp3",
36 | "mimeType" => "audio/mpeg",
37 | "displayName" => "asset.mp3"
38 | }
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/gemini/supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Supervisor do
2 | @moduledoc """
3 | Top-level supervisor for the Gemini application.
4 |
5 | Manages the streaming infrastructure, tool execution runtime, and rate limiting,
6 | providing a unified supervision tree for all Gemini components.
7 | """
8 |
9 | use Supervisor
10 |
11 | alias Gemini.Streaming.UnifiedManager
12 | alias Gemini.RateLimiter.Manager, as: RateLimitManager
13 | alias Altar.LATER.Registry
14 |
15 | @doc """
16 | Start the Gemini supervisor.
17 | """
18 | @spec start_link(term()) :: Supervisor.on_start()
19 | def start_link(init_arg \\ :ok) do
20 | Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
21 | end
22 |
23 | @impl true
24 | @spec init(term()) :: {:ok, {Supervisor.sup_flags(), [Supervisor.child_spec()]}}
25 | def init(_init_arg) do
26 | children = [
27 | # Rate limiting infrastructure (must start first)
28 | {RateLimitManager, []},
29 | # Streaming infrastructure
30 | {UnifiedManager, []},
31 | # Tool execution registry with registered name for discoverability
32 | {Registry, name: Gemini.Tools.Registry}
33 | ]
34 |
35 | opts = [strategy: :one_for_one]
36 | Supervisor.init(children, opts)
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/oldDocs/docs/spec/GEMINI-DOCS-01-QUICKSTART.md:
--------------------------------------------------------------------------------
1 | # Gemini API quickstart
2 |
3 | This quickstart shows you how to install your SDK of choice from the new Google Gen AI SDK, and then make your first Gemini API request.
4 |
5 | **Note:** We've recently updated our code snippets to use the new Google GenAI SDK, which is the recommended library for accessing Gemini API. You can find out more about the new SDK and legacy ones on the Libraries page.
6 |
7 | Python | JavaScript | Go | Apps Script | REST
8 |
9 | ## Make your first request
10 |
11 | Get a Gemini API key in Google AI Studio.
12 |
13 | Use the `generateContent` method to send a request to the Gemini API.
14 |
15 | ```shell
16 | curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=$YOUR_API_KEY" \
17 | -H 'Content-Type: application/json' \
18 | -X POST \
19 | -d '{
20 | "contents": [
21 | {
22 | "parts": [
23 | {
24 | "text": "Explain how AI works in a few words"
25 | }
26 | ]
27 | }
28 | ]
29 | }'
30 | ```
31 |
32 | ## What's next
33 |
34 | Now that you made your first API request, you might want to explore the following guides that show Gemini in action:
35 |
36 | * Text generation
37 | * Vision
38 | * Long context
39 |
40 |
--------------------------------------------------------------------------------
/test/gemini/types/common/media_resolution_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.MediaResolutionTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.MediaResolution
5 |
6 | describe "from_api/1" do
7 | test "maps API strings to atoms" do
8 | assert MediaResolution.from_api("MEDIA_RESOLUTION_LOW") == :media_resolution_low
9 | assert MediaResolution.from_api("MEDIA_RESOLUTION_MEDIUM") == :media_resolution_medium
10 | assert MediaResolution.from_api("MEDIA_RESOLUTION_HIGH") == :media_resolution_high
11 | end
12 |
13 | test "returns unspecified for unknown values" do
14 | assert MediaResolution.from_api("SOMETHING") == :media_resolution_unspecified
15 | assert MediaResolution.from_api(nil) == nil
16 | end
17 | end
18 |
19 | describe "to_api/1" do
20 | test "maps atoms to API strings" do
21 | assert MediaResolution.to_api(:media_resolution_low) == "MEDIA_RESOLUTION_LOW"
22 | assert MediaResolution.to_api(:media_resolution_medium) == "MEDIA_RESOLUTION_MEDIUM"
23 | assert MediaResolution.to_api(:media_resolution_high) == "MEDIA_RESOLUTION_HIGH"
24 | end
25 |
26 | test "defaults to unspecified string for unknown atoms" do
27 | assert MediaResolution.to_api(:other) == "MEDIA_RESOLUTION_UNSPECIFIED"
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/gemini/types/response/modality_token_count.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.ModalityTokenCount do
2 | @moduledoc """
3 | Token counting information for a single modality.
4 | """
5 |
6 | use TypedStruct
7 |
8 | alias Gemini.Types.Modality
9 |
10 | @derive Jason.Encoder
11 | typedstruct do
12 | field(:modality, Modality.t() | :document | nil, default: nil)
13 | field(:token_count, integer() | nil, default: nil)
14 | end
15 |
16 | @doc """
17 | Parse from API payload.
18 | """
19 | @spec from_api(map() | nil) :: t() | nil
20 | def from_api(nil), do: nil
21 |
22 | def from_api(%{} = data) do
23 | %__MODULE__{
24 | modality:
25 | data
26 | |> Map.get("modality")
27 | |> Kernel.||(Map.get(data, :modality))
28 | |> Modality.from_api(),
29 | token_count: Map.get(data, "tokenCount") || Map.get(data, :token_count)
30 | }
31 | end
32 |
33 | @doc """
34 | Convert to API payload map.
35 | """
36 | @spec to_api(t() | nil) :: map() | nil
37 | def to_api(nil), do: nil
38 |
39 | def to_api(%__MODULE__{} = data) do
40 | %{
41 | "modality" => Modality.to_api(data.modality),
42 | "tokenCount" => data.token_count
43 | }
44 | |> Enum.reject(fn {_k, v} -> is_nil(v) end)
45 | |> Enum.into(%{})
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/docs/INTEGRATION_NOTES.md:
--------------------------------------------------------------------------------
1 | # Integration Notes
2 |
3 | ## Files Successfully Moved
4 |
5 | ### From Original Implementation (Production Streaming)
6 | - streaming/manager_v2.ex - Advanced streaming manager ✅
7 | - streaming/manager.ex - Legacy streaming manager ✅
8 | - sse/parser.ex - Excellent SSE parsing ✅
9 | - client/http_streaming.ex - Production HTTP streaming ✅
10 | - client/http.ex - Standard HTTP client ✅
11 | - auth/* - Comprehensive auth strategies ✅
12 | - Core infrastructure (application, config, error, telemetry) ✅
13 | - Working type definitions ✅
14 | - Main API implementations ✅
15 |
16 | ### From Refactor2 (Clean Architecture)
17 | - Enhanced client with better error handling ✅
18 | - Improved error types and handling ✅
19 | - Clean API implementations ✅
20 | - Enhanced request/response types ✅
21 | - Improved main module structure ✅
22 |
23 | ## Files Created for Integration
24 | - auth/multi_auth_coordinator.ex - Needs implementation
25 | - apis/coordinator.ex - Needs implementation
26 | - streaming/unified_manager.ex - Needs implementation
27 |
28 | ## Next Steps
29 | 1. Implement the coordination files
30 | 2. Merge the best parts of both error handling systems
31 | 3. Integrate the enhanced API implementations with streaming
32 | 4. Test multi-auth concurrent usage
33 | 5. Create comprehensive test suite
34 |
--------------------------------------------------------------------------------
/lib/gemini/types/common/file_data.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.FileData do
2 | @moduledoc """
3 | URI-based file data reference used in parts and tool results.
4 | """
5 |
6 | use TypedStruct
7 |
8 | @derive Jason.Encoder
9 | typedstruct do
10 | field(:file_uri, String.t(), enforce: true)
11 | field(:mime_type, String.t(), enforce: true)
12 | field(:display_name, String.t() | nil, default: nil)
13 | end
14 |
15 | @doc """
16 | Parse file data from API response.
17 | """
18 | @spec from_api(map() | nil) :: t() | nil
19 | def from_api(nil), do: nil
20 |
21 | def from_api(%{} = data) do
22 | %__MODULE__{
23 | file_uri: Map.get(data, "fileUri") || Map.get(data, :file_uri),
24 | mime_type: Map.get(data, "mimeType") || Map.get(data, :mime_type),
25 | display_name: Map.get(data, "displayName") || Map.get(data, :display_name)
26 | }
27 | end
28 |
29 | @doc """
30 | Convert file data to API camelCase map.
31 | """
32 | @spec to_api(t() | nil) :: map() | nil
33 | def to_api(nil), do: nil
34 |
35 | def to_api(%__MODULE__{} = file_data) do
36 | %{
37 | "fileUri" => file_data.file_uri,
38 | "mimeType" => file_data.mime_type,
39 | "displayName" => file_data.display_name
40 | }
41 | |> Enum.reject(fn {_k, v} -> is_nil(v) end)
42 | |> Enum.into(%{})
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/test/gemini/types/common/speech_config_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.SpeechConfigTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.{PrebuiltVoiceConfig, SpeechConfig, VoiceConfig}
5 |
6 | describe "to_api/1" do
7 | test "serializes language and voice" do
8 | config = %SpeechConfig{
9 | language_code: "en-US",
10 | voice_config: %VoiceConfig{
11 | prebuilt_voice_config: %PrebuiltVoiceConfig{voice_name: "Puck"}
12 | }
13 | }
14 |
15 | assert SpeechConfig.to_api(config) == %{
16 | "languageCode" => "en-US",
17 | "voiceConfig" => %{
18 | "prebuiltVoiceConfig" => %{"voiceName" => "Puck"}
19 | }
20 | }
21 | end
22 | end
23 |
24 | describe "from_api/1" do
25 | test "parses nested voice config" do
26 | payload = %{
27 | "languageCode" => "fr-FR",
28 | "voiceConfig" => %{
29 | "prebuiltVoiceConfig" => %{"voiceName" => "Aoede"}
30 | }
31 | }
32 |
33 | assert %SpeechConfig{
34 | language_code: "fr-FR",
35 | voice_config: %VoiceConfig{
36 | prebuilt_voice_config: %PrebuiltVoiceConfig{voice_name: "Aoede"}
37 | }
38 | } = SpeechConfig.from_api(payload)
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/test/support/auth_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Test.AuthHelpers do
2 | @moduledoc """
3 | Shared auth detection for tests to avoid duplicated environment checks.
4 | """
5 |
6 | alias Gemini.Config
7 |
8 | @type auth_state :: {:ok, :gemini | :vertex_ai, map()} | :missing
9 |
10 | @doc """
11 | Detect configured auth using Config.auth_config/0.
12 |
13 | Returns:
14 | - {:ok, :gemini, %{api_key: key}} when a Gemini API key is configured
15 | - {:ok, :vertex_ai, creds} when Vertex credentials include project_id and location plus token/key
16 | - :missing otherwise
17 | """
18 | @spec detect_auth() :: auth_state()
19 | def detect_auth do
20 | case Config.auth_config() do
21 | %{type: :gemini, credentials: %{api_key: key}} when is_binary(key) and key != "" ->
22 | {:ok, :gemini, %{api_key: key}}
23 |
24 | %{type: :vertex_ai, credentials: creds} ->
25 | project = Map.get(creds, :project_id)
26 | location = Map.get(creds, :location)
27 |
28 | token =
29 | Map.get(creds, :access_token) ||
30 | Map.get(creds, :service_account_key) ||
31 | Map.get(creds, :service_account_data)
32 |
33 | if project && location && token do
34 | {:ok, :vertex_ai, creds}
35 | else
36 | :missing
37 | end
38 |
39 | _ ->
40 | :missing
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/test/gemini/types/common/function_response_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.FunctionResponseTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.FunctionResponse
5 |
6 | describe "from_api/1" do
7 | test "parses camelCase keys" do
8 | payload = %{
9 | "name" => "lookup",
10 | "response" => %{"result" => "ok"},
11 | "id" => "call-1",
12 | "willContinue" => true,
13 | "scheduling" => "WHEN_IDLE"
14 | }
15 |
16 | resp = FunctionResponse.from_api(payload)
17 |
18 | assert resp.name == "lookup"
19 | assert resp.response == %{"result" => "ok"}
20 | assert resp.id == "call-1"
21 | assert resp.will_continue == true
22 | assert resp.scheduling == :when_idle
23 | end
24 |
25 | test "returns nil for nil input" do
26 | assert FunctionResponse.from_api(nil) == nil
27 | end
28 | end
29 |
30 | describe "to_api/1" do
31 | test "converts atoms and booleans to API format" do
32 | struct = %FunctionResponse{
33 | name: "lookup",
34 | response: %{"result" => "ok"},
35 | id: "call-1",
36 | will_continue: false,
37 | scheduling: :when_idle
38 | }
39 |
40 | assert FunctionResponse.to_api(struct) == %{
41 | "name" => "lookup",
42 | "response" => %{"result" => "ok"},
43 | "willContinue" => false,
44 | "scheduling" => "WHEN_IDLE"
45 | }
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/test/gemini/auth_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.AuthTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Auth
5 | alias Gemini.Auth.{GeminiStrategy, VertexStrategy}
6 |
7 | describe "strategy/1" do
8 | test "returns GeminiStrategy for :gemini auth type" do
9 | assert Auth.strategy(:gemini) == GeminiStrategy
10 | end
11 |
12 | test "returns VertexStrategy for :vertex auth type" do
13 | assert Auth.strategy(:vertex) == VertexStrategy
14 | end
15 |
16 | test "raises error for unsupported auth type" do
17 | assert_raise ArgumentError, "Unsupported auth type: :invalid", fn ->
18 | Auth.strategy(:invalid)
19 | end
20 | end
21 | end
22 |
23 | describe "authenticate/2" do
24 | test "delegates to strategy authenticate/1" do
25 | config = %{auth_type: :gemini, api_key: "test-key"}
26 |
27 | assert {:ok, headers} = Auth.authenticate(GeminiStrategy, config)
28 | assert is_list(headers)
29 | assert {"x-goog-api-key", "test-key"} in headers
30 | end
31 |
32 | test "returns error when strategy authentication fails" do
33 | # missing api_key
34 | config = %{auth_type: :gemini}
35 |
36 | assert {:error, _} = Auth.authenticate(GeminiStrategy, config)
37 | end
38 | end
39 |
40 | describe "base_url/2" do
41 | test "delegates to strategy base_url/1" do
42 | config = %{auth_type: :gemini}
43 |
44 | assert Auth.base_url(GeminiStrategy, config) ==
45 | "https://generativelanguage.googleapis.com/v1beta"
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/gemini/types/common/media_resolution.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.MediaResolution do
2 | @moduledoc """
3 | Media resolution enum for controlling token allocation on media inputs.
4 | """
5 |
6 | @type t ::
7 | :media_resolution_unspecified
8 | | :media_resolution_low
9 | | :media_resolution_medium
10 | | :media_resolution_high
11 |
12 | @api_values %{
13 | "MEDIA_RESOLUTION_UNSPECIFIED" => :media_resolution_unspecified,
14 | "MEDIA_RESOLUTION_LOW" => :media_resolution_low,
15 | "MEDIA_RESOLUTION_MEDIUM" => :media_resolution_medium,
16 | "MEDIA_RESOLUTION_HIGH" => :media_resolution_high
17 | }
18 |
19 | @reverse_api_values %{
20 | media_resolution_unspecified: "MEDIA_RESOLUTION_UNSPECIFIED",
21 | media_resolution_low: "MEDIA_RESOLUTION_LOW",
22 | media_resolution_medium: "MEDIA_RESOLUTION_MEDIUM",
23 | media_resolution_high: "MEDIA_RESOLUTION_HIGH"
24 | }
25 |
26 | @doc """
27 | Convert API value to enum atom.
28 | """
29 | @spec from_api(String.t() | nil) :: t() | nil
30 | def from_api(nil), do: nil
31 | def from_api(value) when is_atom(value), do: value
32 |
33 | def from_api(value) when is_binary(value),
34 | do: Map.get(@api_values, value, :media_resolution_unspecified)
35 |
36 | @doc """
37 | Convert enum atom to API string.
38 | """
39 | @spec to_api(t() | atom() | nil) :: String.t() | nil
40 | def to_api(nil), do: nil
41 | def to_api(value) when is_binary(value), do: value
42 | def to_api(value), do: Map.get(@reverse_api_values, value, "MEDIA_RESOLUTION_UNSPECIFIED")
43 | end
44 |
--------------------------------------------------------------------------------
/test/gemini/types/common/part_new_fields_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.PartNewFieldsTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.APIs.Coordinator
5 | alias Gemini.Types.{FileData, FunctionResponse, Part}
6 |
7 | test "Part struct supports file_data, function_response, and thought" do
8 | file_data = %FileData{file_uri: "gs://bucket/file.pdf", mime_type: "application/pdf"}
9 | func_resp = %FunctionResponse{name: "lookup", response: %{"result" => "ok"}}
10 |
11 | part = %Part{
12 | text: "hello",
13 | file_data: file_data,
14 | function_response: func_resp,
15 | thought: true
16 | }
17 |
18 | assert part.file_data == file_data
19 | assert part.function_response == func_resp
20 | assert part.thought == true
21 | end
22 |
23 | test "__test_format_part__/1 emits camelCase keys for new fields" do
24 | file_data = %FileData{file_uri: "gs://bucket/file.pdf", mime_type: "application/pdf"}
25 | func_resp = %FunctionResponse{name: "lookup", response: %{"result" => "ok"}}
26 |
27 | api_part =
28 | Coordinator.__test_format_part__(%Part{
29 | file_data: file_data,
30 | function_response: func_resp,
31 | thought: true
32 | })
33 |
34 | assert api_part[:fileData] == %{
35 | "fileUri" => "gs://bucket/file.pdf",
36 | "mimeType" => "application/pdf"
37 | }
38 |
39 | assert api_part[:functionResponse]["name"] == "lookup"
40 | assert api_part[:functionResponse]["response"] == %{"result" => "ok"}
41 | assert api_part[:thought] == true
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/examples/structured_outputs_basic.exs:
--------------------------------------------------------------------------------
1 | # Structured Outputs Basic Example
2 | # Run with: mix run examples/structured_outputs_basic.exs
3 |
4 | defmodule BasicExample do
5 | alias Gemini.Config
6 | alias Gemini.Types.GenerationConfig
7 |
8 | def run do
9 | IO.puts("\n🚀 Basic Structured Outputs Example\n")
10 |
11 | # Example 1: Simple Q&A
12 | schema = %{
13 | "type" => "object",
14 | "properties" => %{
15 | "answer" => %{"type" => "string"},
16 | "confidence" => %{"type" => "number", "minimum" => 0, "maximum" => 1}
17 | }
18 | }
19 |
20 | config = GenerationConfig.structured_json(schema)
21 |
22 | case Gemini.generate(
23 | "What is 2+2? Rate confidence.",
24 | model: Config.default_model(),
25 | generation_config: config
26 | ) do
27 | {:ok, response} ->
28 | {:ok, text} = Gemini.extract_text(response)
29 | {:ok, data} = Jason.decode(text)
30 |
31 | IO.puts("✅ Answer: #{data["answer"]}")
32 |
33 | # Handle both integer and float confidence values
34 | confidence = data["confidence"]
35 |
36 | confidence_str =
37 | if is_float(confidence) do
38 | Float.round(confidence, 2)
39 | else
40 | confidence
41 | end
42 |
43 | IO.puts(" Confidence: #{confidence_str}")
44 |
45 | {:error, error} ->
46 | IO.puts("❌ Error: #{inspect(error)}")
47 | end
48 | end
49 | end
50 |
51 | if System.get_env("GEMINI_API_KEY") do
52 | BasicExample.run()
53 | else
54 | IO.puts("⚠️ Set GEMINI_API_KEY to run")
55 | end
56 |
--------------------------------------------------------------------------------
/lib/gemini/types/request/inlined_embed_content_requests.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Request.InlinedEmbedContentRequests do
2 | @moduledoc """
3 | Container for multiple inlined embedding requests in a batch.
4 |
5 | Wraps a list of InlinedEmbedContentRequest structs for submission
6 | as part of an async batch embedding job.
7 |
8 | ## Fields
9 |
10 | - `requests`: List of InlinedEmbedContentRequest structs
11 |
12 | ## Examples
13 |
14 | %InlinedEmbedContentRequests{
15 | requests: [
16 | %InlinedEmbedContentRequest{request: embed_req1},
17 | %InlinedEmbedContentRequest{request: embed_req2}
18 | ]
19 | }
20 | """
21 |
22 | alias Gemini.Types.Request.InlinedEmbedContentRequest
23 |
24 | @enforce_keys [:requests]
25 | defstruct [:requests]
26 |
27 | @type t :: %__MODULE__{
28 | requests: [InlinedEmbedContentRequest.t()]
29 | }
30 |
31 | @doc """
32 | Creates a new container for inlined requests.
33 |
34 | ## Parameters
35 |
36 | - `requests`: List of InlinedEmbedContentRequest structs
37 |
38 | ## Examples
39 |
40 | InlinedEmbedContentRequests.new([req1, req2, req3])
41 | """
42 | @spec new([InlinedEmbedContentRequest.t()]) :: t()
43 | def new(requests) when is_list(requests) do
44 | %__MODULE__{requests: requests}
45 | end
46 |
47 | @doc """
48 | Converts the inlined requests container to API-compatible map format.
49 | """
50 | @spec to_api_map(t()) :: map()
51 | def to_api_map(%__MODULE__{requests: requests}) do
52 | %{
53 | "requests" => Enum.map(requests, &InlinedEmbedContentRequest.to_api_map/1)
54 | }
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/repro_concurrency_gate.exs:
--------------------------------------------------------------------------------
1 | alias Gemini.RateLimiter.{Manager, ConcurrencyGate}
2 |
3 | defmodule ConcurrencyGateRepro do
4 | def run(iterations \\ 500, tasks_per_round \\ 8, sleep_ms \\ 10) do
5 | ConcurrencyGate.init()
6 | Manager.reset_all()
7 |
8 | for i <- 1..iterations do
9 | counter = :atomics.new(1, [])
10 | max_seen = :atomics.new(1, [])
11 | model = "repro-gate-#{i}-#{System.unique_integer([:positive])}"
12 |
13 | request_fn = fn ->
14 | current = :atomics.add_get(counter, 1, 1)
15 | bump_max(max_seen, current)
16 | Process.sleep(sleep_ms)
17 | :atomics.add(counter, 1, -1)
18 | {:ok, :done}
19 | end
20 |
21 | tasks =
22 | for _ <- 1..tasks_per_round do
23 | Task.async(fn ->
24 | Manager.execute(request_fn, model, max_concurrency_per_model: 1)
25 | end)
26 | end
27 |
28 | results = Task.await_many(tasks, 5_000)
29 |
30 | unless Enum.all?(results, &match?({:ok, _}, &1)),
31 | do: raise("unexpected result: #{inspect(results)}")
32 |
33 | peak = :atomics.get(max_seen, 1)
34 |
35 | if peak > 1 do
36 | IO.puts("FAIL iteration=#{i} peak=#{peak} tasks=#{tasks_per_round} sleep_ms=#{sleep_ms}")
37 | System.halt(1)
38 | end
39 | end
40 |
41 | IO.puts(
42 | "PASS no overlaps after #{iterations} iterations (tasks=#{tasks_per_round}, sleep_ms=#{sleep_ms})"
43 | )
44 | end
45 |
46 | defp bump_max(ref, value) do
47 | current = :atomics.get(ref, 1)
48 |
49 | if value > current do
50 | :atomics.compare_exchange(ref, 1, current, value)
51 | end
52 |
53 | :ok
54 | end
55 | end
56 |
57 | ConcurrencyGateRepro.run()
58 |
--------------------------------------------------------------------------------
/test/gemini/types/generation_config_new_fields_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.GenerationConfigNewFieldsTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.{GenerationConfig, SpeechConfig}
5 |
6 | describe "new fields on struct" do
7 | test "sets seed and response_modalities" do
8 | config = GenerationConfig.new(seed: 123, response_modalities: [:text, :audio])
9 |
10 | assert config.seed == 123
11 | assert config.response_modalities == [:text, :audio]
12 | end
13 |
14 | test "accepts speech_config and media_resolution" do
15 | speech = %SpeechConfig{language_code: "en-US"}
16 |
17 | config =
18 | GenerationConfig.new(speech_config: speech, media_resolution: :media_resolution_high)
19 |
20 | assert config.speech_config == speech
21 | assert config.media_resolution == :media_resolution_high
22 | end
23 | end
24 |
25 | describe "helpers" do
26 | test "response_modalities/2 sets modalities" do
27 | config = GenerationConfig.response_modalities([:text, :image])
28 | assert config.response_modalities == [:text, :image]
29 | end
30 |
31 | test "seed/2 sets deterministic seed" do
32 | assert GenerationConfig.seed(GenerationConfig.new(), 77).seed == 77
33 | end
34 |
35 | test "media_resolution/2 sets resolution" do
36 | assert GenerationConfig.media_resolution(:media_resolution_low).media_resolution ==
37 | :media_resolution_low
38 | end
39 |
40 | test "speech_config/2 sets speech config" do
41 | config = GenerationConfig.speech_config(%SpeechConfig{language_code: "es-ES"})
42 | assert %SpeechConfig{language_code: "es-ES"} = config.speech_config
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/lib/gemini/types/response/embed_content_response.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.EmbedContentResponse do
2 | @moduledoc """
3 | Response structure for embedding content requests.
4 |
5 | Contains the generated embedding vector from the input content.
6 |
7 | ## Fields
8 |
9 | - `embedding`: The content embedding containing the numerical vector
10 |
11 | ## Examples
12 |
13 | %EmbedContentResponse{
14 | embedding: %ContentEmbedding{
15 | values: [0.123, -0.456, 0.789, ...]
16 | }
17 | }
18 | """
19 |
20 | alias Gemini.Types.Response.ContentEmbedding
21 |
22 | @enforce_keys [:embedding]
23 | defstruct [:embedding]
24 |
25 | @type t :: %__MODULE__{
26 | embedding: ContentEmbedding.t()
27 | }
28 |
29 | @doc """
30 | Creates a new embedding response from API response data.
31 |
32 | ## Parameters
33 |
34 | - `data`: Map containing the API response
35 |
36 | ## Examples
37 |
38 | EmbedContentResponse.from_api_response(%{
39 | "embedding" => %{"values" => [0.1, 0.2, 0.3]}
40 | })
41 | """
42 | @spec from_api_response(map()) :: t()
43 | def from_api_response(%{"embedding" => embedding_data}) do
44 | %__MODULE__{
45 | embedding: ContentEmbedding.from_api_response(embedding_data)
46 | }
47 | end
48 |
49 | @doc """
50 | Extracts the embedding values as a list of floats.
51 |
52 | ## Examples
53 |
54 | response = %EmbedContentResponse{...}
55 | values = EmbedContentResponse.get_values(response)
56 | # => [0.123, -0.456, 0.789, ...]
57 | """
58 | @spec get_values(t()) :: [float()]
59 | def get_values(%__MODULE__{embedding: embedding}) do
60 | ContentEmbedding.get_values(embedding)
61 | end
62 | end
63 |
--------------------------------------------------------------------------------
/test/gemini/auth/gemini_strategy_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Auth.GeminiStrategyTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Auth.GeminiStrategy
5 |
6 | describe "authenticate/1" do
7 | test "returns headers with API key when present" do
8 | config = %{api_key: "test-api-key-123"}
9 |
10 | assert {:ok, headers} = GeminiStrategy.authenticate(config)
11 |
12 | assert headers == [
13 | {"Content-Type", "application/json"},
14 | {"x-goog-api-key", "test-api-key-123"}
15 | ]
16 | end
17 |
18 | test "returns error when API key is missing" do
19 | config = %{}
20 |
21 | assert {:error, error} = GeminiStrategy.authenticate(config)
22 | assert error =~ "API key is missing"
23 | end
24 |
25 | test "returns error when API key is nil" do
26 | config = %{api_key: nil}
27 |
28 | assert {:error, error} = GeminiStrategy.authenticate(config)
29 | assert error =~ "API key is nil"
30 | end
31 |
32 | test "returns error when API key is empty string" do
33 | config = %{api_key: ""}
34 |
35 | assert {:error, error} = GeminiStrategy.authenticate(config)
36 | assert error =~ "API key is empty"
37 | end
38 | end
39 |
40 | describe "base_url/1" do
41 | test "returns Gemini API base URL" do
42 | config = %{}
43 |
44 | assert GeminiStrategy.base_url(config) == "https://generativelanguage.googleapis.com/v1beta"
45 | end
46 |
47 | test "base URL is consistent regardless of config" do
48 | config1 = %{api_key: "key1"}
49 | config2 = %{api_key: "key2", project_id: "project"}
50 |
51 | assert GeminiStrategy.base_url(config1) == GeminiStrategy.base_url(config2)
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/docs/20251204/context-caching-enhancement/EXECUTION_PLAN.md:
--------------------------------------------------------------------------------
1 | # Context Caching Execution Plan (v0.6.0)
2 |
3 | **Goal:** Achieve full parity with Python `google-genai` caching while fixing current gaps (Vertex paths, config mismatch, formatting) and shipping tested Elixir APIs.
4 |
5 | ## Scope (Implement)
6 | - Cache creation: `system_instruction`, `tools`, `tool_config`, `kms_key_name` (Vertex only), `fileData`/`file_uri`.
7 | - Resource normalization: cache names + model names for Vertex/Gemini.
8 | - Top-level `Gemini.*cache*` delegations.
9 | - Usage metadata struct expansion.
10 | - Model validation warning for cache-capable models.
11 | - Config alignment for `Gemini.configure/2` vs `Gemini.Config.auth_config/0`.
12 | - Robust part formatting (tool/function/response/thought/file).
13 |
14 | ## Test Strategy (TDD)
15 | - Unit: format/normalization helpers, request bodies, model validation warning path, config alignment. Use deterministic assertions; no sleeps; isolate side effects (per Supertester principles).
16 | - Live (tag `:live_api`): create cache with `system_instruction` + `file_uri`; use cache in generate; Vertex name normalization path (skip-friendly when env missing).
17 | - No external network in unit tests; live tests guard on env like existing suite.
18 |
19 | ## Steps
20 | 1) Add failing unit tests for new features and normalization helpers.
21 | 2) Add/extend live tests (skip-aware) covering `system_instruction` + `file_uri` usage.
22 | 3) Implement features/fixes to satisfy tests.
23 | 4) Update docs (README, plan docs) and bump version to 0.6.0 with changelog.
24 | 5) Run unit tests; provide command to run only new live tests.
25 |
26 | ## Deliverables
27 | - Updated code + tests with all unit tests passing.
28 | - New/updated docs and changelog entry for 0.6.0 (2025-12-04).
29 |
--------------------------------------------------------------------------------
/docs/20251203/gemini_rate_limits/adrs/ADR-0004-telemetry-and-config.md:
--------------------------------------------------------------------------------
1 | # ADR 0004: Telemetry, configuration, and surfacing rate-limit state
2 |
3 | - Status: Accepted
4 | - Date: 2025-12-04
5 |
6 | ## Context
7 | - Operators need visibility into pacing, retries, and quotas without inspecting logs manually.
8 | - Applications (e.g., Lumainus) should not duplicate configuration; they should tune a few knobs and consume consistent signals.
9 |
10 | ## Decision
11 | - Emit telemetry events:
12 | - `[:gemini_ex, :request, :start|:stop|:error]` with model, location, usage, duration.
13 | - `[:gemini_ex, :rate_limit, :wait]` when blocking, with `retry_at` and reason (quota metric/id).
14 | - `[:gemini_ex, :rate_limit, :error]` when retries exhausted.
15 | - Configuration surface (with sane defaults):
16 | - `max_concurrency_per_model`
17 | - `max_attempts`
18 | - `base_backoff_ms` and `jitter`
19 | - `non_blocking` (return early vs. wait)
20 | - `logging` toggles (debug vs. quiet)
21 | - `adaptive_concurrency`/ceiling and `profile: :dev | :prod | :custom`
22 | - Return structured errors to callers (`{:error, {:rate_limited, retry_at, details}}`) instead of ad-hoc strings.
23 | - Testing strategy:
24 | - Assert telemetry events fire for request start/stop/error and rate_limit wait/error against the fake server.
25 | - Verify structured errors include retry_at/details; ensure defaults are ON and opt-outs bypass.
26 | - Keep tests fully async/isolated using Supertester; no sleeps.
27 |
28 | ## Consequences
29 | - Observability is standardized; ops can alert on telemetry instead of scraping logs.
30 | - Apps get a small, clear set of knobs; defaults protect most workloads automatically.
31 | - Future UX improvements (e.g., “please retry after Xs”) become trivial with the structured error surface.
32 |
--------------------------------------------------------------------------------
/docs/20251203/gemini_rate_limits/adrs/ADR-0002-retries-and-backoff.md:
--------------------------------------------------------------------------------
1 | # ADR 0002: Retries and backoff for Gemini calls
2 |
3 | - Status: Accepted
4 | - Date: 2025-12-04
5 |
6 | ## Context
7 | - Gemini 429 responses include RetryInfo; transient network/5xx failures also occur.
8 | - Current callers fail fast on 429, producing broken report sections when many calls launch together.
9 |
10 | ## Decision
11 | - Implement retry policy inside gemini_ex client wrapper:
12 | - On 429, parse RetryInfo.retryDelay and back off exactly that duration; fall back to exponential backoff with jitter when missing.
13 | - On transient network/5xx, use exponential backoff with jitter (configurable attempts).
14 | - On non-retriable 4xx (except 429), fail fast.
15 | - Cap retries (configurable `max_attempts`) and return structured error `{:error, {:rate_limited, retry_at, details}}` or `{:error, {:transient_failure, attempts, last_reason}}` when exhausted.
16 | - Coordinate with the rate limiter to avoid stacked/double retries; 429 handling lives in the rate-limit layer, transient network/5xx in the generic retry layer.
17 | - Per-call override: `non_blocking: true` returns immediately with structured rate-limit info instead of sleeping.
18 | - Testing strategy:
19 | - Fake server returns 429 with RetryInfo; assert backoff honors server delay, structured error on exhaustion, telemetry emitted.
20 | - Fake server returns 5xx/transport errors; assert exponential backoff with jitter (bounded attempts) and no double-retry with rate limiter.
21 | - Ensure default is ON in tests; verify opt-outs still bypass.
22 |
23 | ## Consequences
24 | - Sections/jobs become resilient to temporary quota spikes and flaky transports.
25 | - Callers can choose to block until retry or opt out via `non_blocking: true`.
26 | - Error surfaces are consistent, enabling UI messaging and logging without ad-hoc parsing.
27 |
--------------------------------------------------------------------------------
/docs/20251203/gemini_rate_limits/adrs/ADR-0003-concurrency-and-budgeting.md:
--------------------------------------------------------------------------------
1 | # ADR 0003: Concurrency gating and token budgeting
2 |
3 | - Status: Accepted
4 | - Date: 2025-12-04
5 |
6 | ## Context
7 | - Thundering herd: many section requests launch simultaneously, multiplying token usage and tripping per-minute limits.
8 | - Gemini exposes no “remaining” header, so we must infer safe pacing from recent usage and 429 signals.
9 |
10 | ## Decision
11 | - Introduce per-model concurrency permits (configurable, small defaults like 2–4) in gemini_ex to throttle bursts; allow `nil`/0 to disable. Ship enabled by default; document in migration notes.
12 | - Optional adaptive mode: start low, increase concurrency until a 429 is observed, then back off; cap via a configured ceiling.
13 | - Profiles: support `:dev | :prod | :custom` presets for defaults; custom overrides always win.
14 | - Maintain lightweight token budgets using:
15 | - Estimated tokens from prompt size preflight (to decide if we should wait when near a known retry window).
16 | - Actual `usage` returned by Gemini to update rolling windows.
17 | - When a request would violate a live retry window, enqueue or return a structured “retry_at” response.
18 | - Testing strategy:
19 | - Concurrency gate: set max_concurrency=1, fire N parallel requests at fake server; assert serialized hits (with Supertester harness, no sleeps).
20 | - Adaptive: configure fake to switch to 429 after K hits; assert gate backs off, then raises when 200 resumes.
21 | - Token budgeting: feed fake `usage` data; assert preflight blocks when budget exceeded.
22 | - Verify defaults ON; opt-outs (nil/0 concurrency) skip gating.
23 |
24 | ## Consequences
25 | - Reduces simultaneous token spikes, cutting 429 frequency.
26 | - Adds minimal latency under load; single calls stay fast.
27 | - Creates the foundation for smarter scheduling (priority queues) later without changing app code.
28 |
--------------------------------------------------------------------------------
/lib/gemini/types/common/blob.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Blob do
2 | @moduledoc """
3 | Binary data with MIME type for Gemini API.
4 | """
5 |
6 | use TypedStruct
7 |
8 | @derive Jason.Encoder
9 | typedstruct do
10 | field(:data, String.t(), enforce: true)
11 | field(:mime_type, String.t(), enforce: true)
12 | end
13 |
14 | @typedoc "Base64 encoded binary data."
15 | @type blob_data :: String.t()
16 |
17 | @typedoc "MIME type of the data."
18 | @type mime_type :: String.t()
19 |
20 | @doc """
21 | Create a new blob with base64 encoded data.
22 | """
23 | @spec new(String.t(), String.t()) :: t()
24 | def new(data, mime_type) when is_binary(data) and is_binary(mime_type) do
25 | encoded_data = Base.encode64(data)
26 |
27 | %__MODULE__{
28 | data: encoded_data,
29 | mime_type: mime_type
30 | }
31 | end
32 |
33 | @doc """
34 | Create a blob from a file path.
35 | """
36 | @spec from_file(String.t()) :: {:ok, t()} | {:error, Gemini.Error.t()}
37 | def from_file(file_path) do
38 | case File.read(file_path) do
39 | {:ok, data} ->
40 | mime_type = determine_mime_type(file_path)
41 | {:ok, new(data, mime_type)}
42 |
43 | {:error, reason} ->
44 | {:error, Gemini.Error.new(:file_error, "Could not read file: #{reason}")}
45 | end
46 | end
47 |
48 | # Simple MIME type detection based on file extension
49 | defp determine_mime_type(file_path) do
50 | case Path.extname(file_path) |> String.downcase() do
51 | ".jpg" -> "image/jpeg"
52 | ".jpeg" -> "image/jpeg"
53 | ".png" -> "image/png"
54 | ".gif" -> "image/gif"
55 | ".webp" -> "image/webp"
56 | ".pdf" -> "application/pdf"
57 | ".mp4" -> "video/mp4"
58 | ".avi" -> "video/avi"
59 | ".mov" -> "video/mov"
60 | ".mp3" -> "audio/mp3"
61 | ".wav" -> "audio/wav"
62 | _ -> "application/octet-stream"
63 | end
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/examples/simple_embedding.exs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env elixir
2 |
3 | # Simple Embedding Example
4 | #
5 | # This is the Elixir equivalent of:
6 | #
7 | # curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-001:embedContent" \
8 | # -H "x-goog-api-key: $GEMINI_API_KEY" \
9 | # -H 'Content-Type: application/json' \
10 | # -d '{"model": "models/gemini-embedding-001",
11 | # "content": {"parts":[{"text": "What is the meaning of life?"}]}
12 | # }'
13 | #
14 | # Usage:
15 | # mix run examples/simple_embedding.exs
16 |
17 | alias Gemini.APIs.Coordinator
18 | alias Gemini.Config
19 | alias Gemini.Types.Response.EmbedContentResponse
20 |
21 | # Simple embedding - equivalent to the curl command
22 | text = "What is the meaning of life?"
23 |
24 | IO.puts("\nEmbedding text: \"#{text}\"\n")
25 |
26 | case Coordinator.embed_content(text, model: Config.get_model(:embedding)) do
27 | {:ok, response} ->
28 | values = EmbedContentResponse.get_values(response)
29 |
30 | IO.puts("✓ Success!")
31 | IO.puts(" Dimensionality: #{length(values)}")
32 | IO.puts(" First 10 values:")
33 |
34 | values
35 | |> Enum.take(10)
36 | |> Enum.with_index(1)
37 | |> Enum.each(fn {value, idx} ->
38 | IO.puts(" [#{idx}] #{value}")
39 | end)
40 |
41 | IO.puts("\n Last 5 values:")
42 |
43 | values
44 | |> Enum.take(-5)
45 | |> Enum.each(fn value ->
46 | IO.puts(" #{value}")
47 | end)
48 |
49 | IO.puts("\n✓ Embedding vector retrieved successfully!")
50 | IO.puts(" Total dimensions: #{length(values)}")
51 |
52 | {:error, reason} ->
53 | IO.puts("✗ Error: #{inspect(reason)}")
54 | System.halt(1)
55 | end
56 |
57 | IO.puts("\n" <> String.duplicate("-", 60))
58 | IO.puts("The embedding vector can now be used for:")
59 | IO.puts(" • Semantic search and retrieval")
60 | IO.puts(" • Text similarity comparison")
61 | IO.puts(" • Clustering and classification")
62 | IO.puts(" • Recommendation systems")
63 | IO.puts(String.duplicate("-", 60) <> "\n")
64 |
--------------------------------------------------------------------------------
/lib/gemini/auth/gemini_strategy.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Auth.GeminiStrategy do
2 | @moduledoc """
3 | Authentication strategy for Google Gemini API using API key.
4 |
5 | This strategy uses the simple x-goog-api-key header authentication
6 | method used by the Gemini API.
7 | """
8 |
9 | @behaviour Gemini.Auth.Strategy
10 |
11 | @base_url "https://generativelanguage.googleapis.com/v1beta"
12 |
13 | @doc """
14 | Authenticate with Gemini API using API key.
15 | """
16 | def authenticate(%{api_key: api_key}) when is_binary(api_key) and api_key != "" do
17 | headers(%{api_key: api_key})
18 | end
19 |
20 | def authenticate(%{api_key: nil}) do
21 | {:error, "API key is nil"}
22 | end
23 |
24 | def authenticate(%{api_key: ""}) do
25 | {:error, "API key is empty"}
26 | end
27 |
28 | def authenticate(%{}) do
29 | {:error, "API key is missing"}
30 | end
31 |
32 | def authenticate(_config) do
33 | {:error, "Invalid configuration for Gemini authentication"}
34 | end
35 |
36 | @impl true
37 | def headers(%{api_key: api_key}) when is_binary(api_key) and api_key != "" do
38 | {:ok,
39 | [
40 | {"Content-Type", "application/json"},
41 | {"x-goog-api-key", api_key}
42 | ]}
43 | end
44 |
45 | def headers(%{api_key: nil}) do
46 | {:error, "API key is nil"}
47 | end
48 |
49 | def headers(%{api_key: ""}) do
50 | {:error, "API key is empty"}
51 | end
52 |
53 | def headers(_credentials) do
54 | {:error, "API key is missing or invalid"}
55 | end
56 |
57 | @impl true
58 | def base_url(_credentials) do
59 | @base_url
60 | end
61 |
62 | @impl true
63 | def build_path(model, endpoint, _credentials) do
64 | # Normalize model name - add "models/" prefix if not present
65 | normalized_model =
66 | if String.starts_with?(model, "models/"), do: model, else: "models/#{model}"
67 |
68 | "#{normalized_model}:#{endpoint}"
69 | end
70 |
71 | @impl true
72 | def refresh_credentials(credentials) do
73 | # API keys don't need refreshing
74 | {:ok, credentials}
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/lib/gemini/types/response/batch_embed_contents_response.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.BatchEmbedContentsResponse do
2 | @moduledoc """
3 | Response structure for batch embedding requests.
4 |
5 | Contains embeddings for multiple content items in the same order as
6 | the input requests.
7 |
8 | ## Fields
9 |
10 | - `embeddings`: List of content embeddings
11 |
12 | ## Examples
13 |
14 | %BatchEmbedContentsResponse{
15 | embeddings: [
16 | %ContentEmbedding{values: [0.1, 0.2, ...]},
17 | %ContentEmbedding{values: [0.3, 0.4, ...]},
18 | %ContentEmbedding{values: [0.5, 0.6, ...]}
19 | ]
20 | }
21 | """
22 |
23 | alias Gemini.Types.Response.ContentEmbedding
24 |
25 | @enforce_keys [:embeddings]
26 | defstruct [:embeddings]
27 |
28 | @type t :: %__MODULE__{
29 | embeddings: [ContentEmbedding.t()]
30 | }
31 |
32 | @doc """
33 | Creates a new batch embedding response from API response data.
34 |
35 | ## Parameters
36 |
37 | - `data`: Map containing the API response
38 |
39 | ## Examples
40 |
41 | BatchEmbedContentsResponse.from_api_response(%{
42 | "embeddings" => [
43 | %{"values" => [0.1, 0.2]},
44 | %{"values" => [0.3, 0.4]}
45 | ]
46 | })
47 | """
48 | @spec from_api_response(map()) :: t()
49 | def from_api_response(%{"embeddings" => embeddings_data}) when is_list(embeddings_data) do
50 | embeddings = Enum.map(embeddings_data, &ContentEmbedding.from_api_response/1)
51 |
52 | %__MODULE__{
53 | embeddings: embeddings
54 | }
55 | end
56 |
57 | @doc """
58 | Gets all embedding values as a list of lists.
59 |
60 | ## Examples
61 |
62 | response = %BatchEmbedContentsResponse{...}
63 | all_values = BatchEmbedContentsResponse.get_all_values(response)
64 | # => [[0.1, 0.2, ...], [0.3, 0.4, ...], ...]
65 | """
66 | @spec get_all_values(t()) :: [[float()]]
67 | def get_all_values(%__MODULE__{embeddings: embeddings}) do
68 | Enum.map(embeddings, &ContentEmbedding.get_values/1)
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/examples/structured_outputs_standalone.exs:
--------------------------------------------------------------------------------
1 | # Standalone Structured Outputs Example
2 | # This version uses Mix.install and can be run from anywhere
3 | #
4 | # IMPORTANT: This example requires gemini_ex v0.4.0 or later!
5 | # The structured_json/2 function is NEW in v0.4.0.
6 | #
7 | # To run during development (from the examples directory):
8 | # elixir structured_outputs_standalone.exs
9 | #
10 | # After v0.4.0 is published to Hex.pm, change the dependency to:
11 | # {:gemini_ex, "~> 0.4.0"}
12 |
13 | Mix.install([
14 | # Change to "~> 0.4.0" after publishing
15 | {:gemini_ex, path: ".."},
16 | {:jason, "~> 1.4"}
17 | ])
18 |
19 | defmodule BasicExample do
20 | alias Gemini.Config
21 | alias Gemini.Types.GenerationConfig
22 |
23 | def run do
24 | IO.puts("\n🚀 Basic Structured Outputs Example\n")
25 |
26 | # Example 1: Simple Q&A
27 | schema = %{
28 | "type" => "object",
29 | "properties" => %{
30 | "answer" => %{"type" => "string"},
31 | "confidence" => %{"type" => "number", "minimum" => 0, "maximum" => 1}
32 | }
33 | }
34 |
35 | config = GenerationConfig.structured_json(schema)
36 |
37 | case Gemini.generate(
38 | "What is 2+2? Rate confidence.",
39 | model: Config.default_model(),
40 | generation_config: config
41 | ) do
42 | {:ok, response} ->
43 | {:ok, text} = Gemini.extract_text(response)
44 | {:ok, data} = Jason.decode(text)
45 |
46 | IO.puts("✅ Answer: #{data["answer"]}")
47 |
48 | # Handle both integer and float confidence values
49 | confidence = data["confidence"]
50 |
51 | confidence_str =
52 | if is_float(confidence) do
53 | Float.round(confidence, 2)
54 | else
55 | confidence
56 | end
57 |
58 | IO.puts(" Confidence: #{confidence_str}")
59 |
60 | {:error, error} ->
61 | IO.puts("❌ Error: #{inspect(error)}")
62 | end
63 | end
64 | end
65 |
66 | if System.get_env("GEMINI_API_KEY") do
67 | BasicExample.run()
68 | else
69 | IO.puts("⚠️ Set GEMINI_API_KEY to run")
70 | end
71 |
--------------------------------------------------------------------------------
/examples/simple_live_test.exs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env elixir
2 |
3 | # Simple Live Tool-Calling Test
4 | # A minimal test to verify automatic tool execution works
5 |
6 | alias Gemini
7 | alias Gemini.Tools
8 | alias Altar.ADM
9 | # alias Altar.ADM.ToolConfig
10 |
11 | # Simple tool that just returns basic info
12 | defmodule SimpleTools do
13 | def get_current_time(%{}) do
14 | %{
15 | current_time: DateTime.utc_now() |> DateTime.to_iso8601(),
16 | timezone: "UTC",
17 | timestamp: System.system_time(:second)
18 | }
19 | end
20 | end
21 |
22 | # Check API key
23 | case System.get_env("GEMINI_API_KEY") do
24 | nil ->
25 | IO.puts("❌ GEMINI_API_KEY not set")
26 | System.halt(1)
27 |
28 | _key ->
29 | IO.puts("✅ API key found")
30 | end
31 |
32 | # Register simple tool
33 | {:ok, tool_declaration} =
34 | ADM.new_function_declaration(%{
35 | name: "get_current_time",
36 | description: "Gets the current UTC time and timestamp",
37 | parameters: %{
38 | type: "object",
39 | properties: %{},
40 | required: []
41 | }
42 | })
43 |
44 | :ok = Tools.register(tool_declaration, &SimpleTools.get_current_time/1)
45 | IO.puts("✅ Tool registered")
46 |
47 | # Test prompt that strongly encourages tool usage
48 | prompt = """
49 | What time is it right now? I need you to use the get_current_time tool to get the exact current time.
50 | You MUST call the get_current_time function to answer this question.
51 | """
52 |
53 | IO.puts("🚀 Testing automatic tool calling...")
54 |
55 | result =
56 | Gemini.generate_content_with_auto_tools(
57 | prompt,
58 | tools: [tool_declaration],
59 | model: Gemini.Config.default_model(),
60 | temperature: 0.1,
61 | turn_limit: 3
62 | )
63 |
64 | case result do
65 | {:ok, response} ->
66 | case Gemini.extract_text(response) do
67 | {:ok, text} ->
68 | IO.puts("\n🎉 SUCCESS!")
69 | IO.puts("Response: #{text}")
70 |
71 | {:error, error} ->
72 | IO.puts("❌ Text extraction failed: #{error}")
73 | end
74 |
75 | {:error, error} ->
76 | IO.puts("❌ Tool calling failed: #{inspect(error)}")
77 | end
78 |
--------------------------------------------------------------------------------
/test/coordinator_integration_test.exs:
--------------------------------------------------------------------------------
1 | defmodule CoordinatorIntegrationTest do
2 | use ExUnit.Case, async: false
3 |
4 | alias Gemini.APIs.Coordinator
5 |
6 | import Gemini.Test.ModelHelpers
7 |
8 | @moduletag :live_api
9 |
10 | describe "Text generation with real API" do
11 | test "generate_content works and extract_text succeeds" do
12 | # Works with either Gemini API or Vertex AI
13 | if auth_available?() do
14 | prompt = "Say 'Hello World' exactly"
15 |
16 | # Test the full flow that was previously failing
17 | case Coordinator.generate_content(prompt) do
18 | {:ok, response} ->
19 | IO.puts("✅ generate_content succeeded")
20 | IO.puts("Response type: #{inspect(response.__struct__)}")
21 |
22 | # This was the failing part - extract_text should now work
23 | case Coordinator.extract_text(response) do
24 | {:ok, text} ->
25 | IO.puts("✅ extract_text succeeded: '#{text}'")
26 | assert String.contains?(text, "Hello")
27 |
28 | {:error, reason} ->
29 | flunk("extract_text failed: #{inspect(reason)}")
30 | end
31 |
32 | {:error, reason} ->
33 | flunk("generate_content failed: #{inspect(reason)}")
34 | end
35 | else
36 | IO.puts("Skipping live API test - no auth configured")
37 | end
38 | end
39 |
40 | test "list_models works and returns models" do
41 | # list_models only works with Gemini API, not Vertex AI
42 | if gemini_api_available?() do
43 | case Coordinator.list_models() do
44 | {:ok, response} ->
45 | IO.puts("✅ list_models succeeded")
46 | IO.puts("Response type: #{inspect(response.__struct__)}")
47 | assert is_struct(response)
48 |
49 | {:error, reason} ->
50 | flunk("list_models failed: #{inspect(reason)}")
51 | end
52 | else
53 | IO.puts(
54 | "Skipping list_models test - requires GEMINI_API_KEY (not supported on Vertex AI)"
55 | )
56 | end
57 | end
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/test/live_api/gemini3_image_preview_live_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.LiveAPI.Gemini3ImagePreviewLiveTest do
2 | @moduledoc """
3 | Live smoke test for `gemini-3-pro-image-preview` using the Gemini API key.
4 |
5 | Run with:
6 | mix test --include live_api test/live_api/gemini3_image_preview_live_test.exs
7 |
8 | Saves the first returned image under the gitignored `generated/` directory.
9 | """
10 |
11 | use ExUnit.Case, async: false
12 |
13 | alias Gemini.Types.GenerationConfig
14 |
15 | @moduletag :live_api
16 | @moduletag timeout: 180_000
17 |
18 | setup do
19 | api_key = System.get_env("GEMINI_API_KEY")
20 |
21 | cond do
22 | is_nil(api_key) or api_key == "" ->
23 | {:ok, skip: true}
24 |
25 | true ->
26 | File.mkdir_p!("generated")
27 | {:ok, skip: false}
28 | end
29 | end
30 |
31 | test "generates an image and writes it to generated/", %{skip: skip?} do
32 | if skip? do
33 | IO.puts("\nSkipping live image preview test: GEMINI_API_KEY not set")
34 | assert true
35 | else
36 | config =
37 | GenerationConfig.new()
38 | |> GenerationConfig.image_config(
39 | aspect_ratio: "1:1",
40 | image_size: "1K"
41 | )
42 | |> GenerationConfig.response_modalities([:image])
43 |
44 | case Gemini.generate("Create a clean, minimal banana icon on white",
45 | model: "gemini-3-pro-image-preview",
46 | generation_config: config
47 | ) do
48 | {:ok, response} ->
49 | images =
50 | for %{content: %{parts: parts}} <- response.candidates,
51 | %{inline_data: %{data: data, mime_type: mime}} <- parts do
52 | {data, mime}
53 | end
54 |
55 | assert images != [], "Expected at least one inline image in the response"
56 |
57 | {data, mime} = hd(images)
58 | assert String.starts_with?(mime, "image/")
59 |
60 | {:ok, bin} = Base.decode64(data)
61 | output_path = Path.join("generated", "gemini3_image_preview_live.png")
62 | File.write!(output_path, bin)
63 |
64 | assert File.exists?(output_path)
65 |
66 | {:error, reason} ->
67 | flunk("Live image preview call failed: #{inspect(reason)}")
68 | end
69 | end
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/docs/gemini_api_reference_2025_10_07/README.md:
--------------------------------------------------------------------------------
1 | # Gemini API Reference Documentation
2 |
3 | Fetched: 2025-10-07
4 |
5 | This directory contains comprehensive reference documentation extracted from the Google Gemini API documentation site.
6 |
7 | ## Contents
8 |
9 | ### 1. IMAGE_UNDERSTANDING.md
10 | **Source:** https://ai.google.dev/gemini-api/docs/image-understanding
11 |
12 | Complete documentation covering:
13 | - Passing images to Gemini (inline data and File API)
14 | - Prompting with multiple images
15 | - Object detection capabilities (Gemini 2.0+)
16 | - Segmentation capabilities (Gemini 2.5+)
17 | - Supported image formats
18 | - Token calculation and limitations
19 | - Tips and best practices
20 | - Complete code examples in Python, JavaScript, Go, and REST/Shell
21 |
22 | **Lines:** 895 | **Size:** ~25KB
23 |
24 | ### 2. THINKING.md
25 | **Source:** https://ai.google.dev/gemini-api/docs/thinking
26 |
27 | Complete documentation covering:
28 | - Generating content with thinking models
29 | - Thinking budgets configuration
30 | - Thought summaries (streaming and non-streaming)
31 | - Thought signatures for multi-turn conversations
32 | - Pricing and token counting
33 | - Supported models (2.5 series)
34 | - Best practices for task complexity
35 | - Integration with tools and capabilities
36 | - Complete code examples in Python, JavaScript, Go, and REST/Shell
37 |
38 | **Lines:** 717 | **Size:** ~21KB
39 |
40 | ## Documentation Quality
41 |
42 | These files contain:
43 | - ✅ Complete and unabridged content from the source pages
44 | - ✅ All code examples in all programming languages (Python, JavaScript, Go, Shell/curl)
45 | - ✅ All tables, lists, notes, warnings, and tips
46 | - ✅ All sections including "Before you begin", "What's next", etc.
47 | - ✅ Proper markdown formatting with headers, code blocks, and links
48 | - ✅ Source URLs and fetch date headers
49 |
50 | ## Usage
51 |
52 | These reference documents are intended to support the Elixir Gemini implementation by providing:
53 | - Comprehensive API behavior documentation
54 | - Code examples showing request/response patterns
55 | - Feature availability and limitations
56 | - Best practices for various use cases
57 |
58 | ## Format
59 |
60 | All documentation is in markdown format with:
61 | - Clear header hierarchy (H1-H6)
62 | - Syntax-highlighted code blocks with language tags
63 | - Preserved inline code formatting
64 | - Maintained link references to Google's documentation
65 | - Clean, readable structure
66 |
--------------------------------------------------------------------------------
/lib/gemini/types/common/safety_setting.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.SafetySetting do
2 | @moduledoc """
3 | Safety settings for content generation.
4 | """
5 |
6 | use TypedStruct
7 |
8 | @type category ::
9 | :harm_category_harassment
10 | | :harm_category_hate_speech
11 | | :harm_category_sexually_explicit
12 | | :harm_category_dangerous_content
13 |
14 | @type threshold ::
15 | :harm_block_threshold_unspecified
16 | | :block_low_and_above
17 | | :block_medium_and_above
18 | | :block_only_high
19 | | :block_none
20 |
21 | @derive Jason.Encoder
22 | typedstruct do
23 | field(:category, category(), enforce: true)
24 | field(:threshold, threshold(), enforce: true)
25 | end
26 |
27 | @doc """
28 | Create a safety setting for harassment content.
29 | """
30 | def harassment(threshold \\ :block_medium_and_above) do
31 | %__MODULE__{
32 | category: :harm_category_harassment,
33 | threshold: threshold
34 | }
35 | end
36 |
37 | @doc """
38 | Create a safety setting for hate speech content.
39 | """
40 | def hate_speech(threshold \\ :block_medium_and_above) do
41 | %__MODULE__{
42 | category: :harm_category_hate_speech,
43 | threshold: threshold
44 | }
45 | end
46 |
47 | @doc """
48 | Create a safety setting for sexually explicit content.
49 | """
50 | def sexually_explicit(threshold \\ :block_medium_and_above) do
51 | %__MODULE__{
52 | category: :harm_category_sexually_explicit,
53 | threshold: threshold
54 | }
55 | end
56 |
57 | @doc """
58 | Create a safety setting for dangerous content.
59 | """
60 | def dangerous_content(threshold \\ :block_medium_and_above) do
61 | %__MODULE__{
62 | category: :harm_category_dangerous_content,
63 | threshold: threshold
64 | }
65 | end
66 |
67 | @doc """
68 | Get default safety settings (medium threshold for all categories).
69 | """
70 | def defaults do
71 | [
72 | harassment(),
73 | hate_speech(),
74 | sexually_explicit(),
75 | dangerous_content()
76 | ]
77 | end
78 |
79 | @doc """
80 | Get permissive safety settings (block only high risk content).
81 | """
82 | def permissive do
83 | [
84 | harassment(:block_only_high),
85 | hate_speech(:block_only_high),
86 | sexually_explicit(:block_only_high),
87 | dangerous_content(:block_only_high)
88 | ]
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/lib/gemini/types/request/inlined_embed_content_request.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Request.InlinedEmbedContentRequest do
2 | @moduledoc """
3 | A single embedding request within an async batch, with optional metadata.
4 |
5 | Used to submit individual embedding requests as part of an async batch operation.
6 | Each request can include metadata for tracking purposes.
7 |
8 | ## Fields
9 |
10 | - `request`: The embedding request (EmbedContentRequest)
11 | - `metadata`: Optional metadata (map) to track request identity
12 |
13 | ## Examples
14 |
15 | # Simple inlined request
16 | %InlinedEmbedContentRequest{
17 | request: %EmbedContentRequest{
18 | model: "models/gemini-embedding-001",
19 | content: %Content{parts: [%Part{text: "Hello world"}]}
20 | }
21 | }
22 |
23 | # With metadata
24 | %InlinedEmbedContentRequest{
25 | request: embed_request,
26 | metadata: %{"document_id" => "doc-123", "category" => "tech"}
27 | }
28 | """
29 |
30 | alias Gemini.Types.Request.EmbedContentRequest
31 |
32 | @enforce_keys [:request]
33 | defstruct [:request, :metadata]
34 |
35 | @type t :: %__MODULE__{
36 | request: EmbedContentRequest.t(),
37 | metadata: map() | nil
38 | }
39 |
40 | @doc """
41 | Creates a new inlined embed content request.
42 |
43 | ## Parameters
44 |
45 | - `request`: The EmbedContentRequest to include
46 | - `opts`: Optional keyword list
47 | - `:metadata`: Metadata map for tracking
48 |
49 | ## Examples
50 |
51 | InlinedEmbedContentRequest.new(embed_request)
52 |
53 | InlinedEmbedContentRequest.new(embed_request,
54 | metadata: %{"id" => "123"}
55 | )
56 | """
57 | @spec new(EmbedContentRequest.t(), keyword()) :: t()
58 | def new(%EmbedContentRequest{} = request, opts \\ []) do
59 | %__MODULE__{
60 | request: request,
61 | metadata: Keyword.get(opts, :metadata)
62 | }
63 | end
64 |
65 | @doc """
66 | Converts the inlined request to API-compatible map format.
67 | """
68 | @spec to_api_map(t()) :: map()
69 | def to_api_map(%__MODULE__{} = inlined_request) do
70 | %{
71 | "request" => EmbedContentRequest.to_api_map(inlined_request.request)
72 | }
73 | |> maybe_put("metadata", inlined_request.metadata)
74 | end
75 |
76 | # Private helpers
77 |
78 | defp maybe_put(map, _key, nil), do: map
79 | defp maybe_put(map, key, value), do: Map.put(map, key, value)
80 | end
81 |
--------------------------------------------------------------------------------
/lib/gemini/types/common/function_response.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.FunctionResponse do
2 | @moduledoc """
3 | Result output of a function call.
4 | """
5 |
6 | use TypedStruct
7 |
8 | @type scheduling :: :scheduling_unspecified | :silent | :when_idle | :interrupt
9 |
10 | @derive Jason.Encoder
11 | typedstruct do
12 | field(:name, String.t(), enforce: true)
13 | field(:response, map(), enforce: true)
14 | field(:id, String.t() | nil, default: nil)
15 | field(:will_continue, boolean() | nil, default: nil)
16 | field(:scheduling, scheduling() | nil, default: nil)
17 | end
18 |
19 | @doc """
20 | Parse function response from API payload.
21 | """
22 | @spec from_api(map() | nil) :: t() | nil
23 | def from_api(nil), do: nil
24 |
25 | def from_api(%{} = data) do
26 | %__MODULE__{
27 | name: Map.get(data, "name") || Map.get(data, :name),
28 | response: Map.get(data, "response") || Map.get(data, :response),
29 | id: Map.get(data, "id") || Map.get(data, :id),
30 | will_continue: Map.get(data, "willContinue") || Map.get(data, :will_continue),
31 | scheduling: scheduling_from_api(Map.get(data, "scheduling") || Map.get(data, :scheduling))
32 | }
33 | end
34 |
35 | @doc """
36 | Convert function response to API camelCase map.
37 | """
38 | @spec to_api(t() | nil) :: map() | nil
39 | def to_api(nil), do: nil
40 |
41 | def to_api(%__MODULE__{} = data) do
42 | %{
43 | "name" => data.name,
44 | "response" => data.response,
45 | "willContinue" => data.will_continue,
46 | "scheduling" => scheduling_to_api(data.scheduling)
47 | }
48 | |> Enum.reject(fn {_k, v} -> is_nil(v) end)
49 | |> Enum.into(%{})
50 | end
51 |
52 | defp scheduling_from_api(nil), do: nil
53 | defp scheduling_from_api("SILENT"), do: :silent
54 | defp scheduling_from_api("WHEN_IDLE"), do: :when_idle
55 | defp scheduling_from_api("INTERRUPT"), do: :interrupt
56 | defp scheduling_from_api("SCHEDULING_UNSPECIFIED"), do: :scheduling_unspecified
57 | defp scheduling_from_api(atom) when is_atom(atom), do: atom
58 | defp scheduling_from_api(_), do: :scheduling_unspecified
59 |
60 | defp scheduling_to_api(nil), do: nil
61 | defp scheduling_to_api(:silent), do: "SILENT"
62 | defp scheduling_to_api(:when_idle), do: "WHEN_IDLE"
63 | defp scheduling_to_api(:interrupt), do: "INTERRUPT"
64 | defp scheduling_to_api(:scheduling_unspecified), do: "SCHEDULING_UNSPECIFIED"
65 | defp scheduling_to_api(_), do: "SCHEDULING_UNSPECIFIED"
66 | end
67 |
--------------------------------------------------------------------------------
/test/gemini/types/response/usage_metadata_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.UsageMetadataTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.Response.{ModalityTokenCount, UsageMetadata}
5 |
6 | describe "from_api/1" do
7 | test "parses new token count fields" do
8 | json = %{
9 | "totalTokenCount" => 100,
10 | "promptTokenCount" => 10,
11 | "candidatesTokenCount" => 20,
12 | "cachedContentTokenCount" => 5,
13 | "thoughtsTokenCount" => 30,
14 | "toolUsePromptTokenCount" => 7
15 | }
16 |
17 | metadata = UsageMetadata.from_api(json)
18 |
19 | assert metadata.total_token_count == 100
20 | assert metadata.prompt_token_count == 10
21 | assert metadata.candidates_token_count == 20
22 | assert metadata.cached_content_token_count == 5
23 | assert metadata.thoughts_token_count == 30
24 | assert metadata.tool_use_prompt_token_count == 7
25 | end
26 |
27 | test "parses token details with modality conversion" do
28 | json = %{
29 | "totalTokenCount" => 10,
30 | "promptTokensDetails" => [
31 | %{"modality" => "TEXT", "tokenCount" => 6},
32 | %{"modality" => "AUDIO", "tokenCount" => 4}
33 | ],
34 | "cacheTokensDetails" => [
35 | %{"modality" => "IMAGE", "tokenCount" => 2}
36 | ],
37 | "responseTokensDetails" => [
38 | %{"modality" => "TEXT", "tokenCount" => 8}
39 | ],
40 | "toolUsePromptTokensDetails" => [
41 | %{"modality" => "TEXT", "tokenCount" => 1}
42 | ],
43 | "trafficType" => "ON_DEMAND"
44 | }
45 |
46 | metadata = UsageMetadata.from_api(json)
47 |
48 | assert [
49 | %ModalityTokenCount{modality: :text, token_count: 6},
50 | %ModalityTokenCount{modality: :audio, token_count: 4}
51 | ] =
52 | metadata.prompt_tokens_details
53 |
54 | assert [%ModalityTokenCount{modality: :image, token_count: 2}] =
55 | metadata.cache_tokens_details
56 |
57 | assert [%ModalityTokenCount{modality: :text, token_count: 8}] =
58 | metadata.response_tokens_details
59 |
60 | assert [%ModalityTokenCount{modality: :text, token_count: 1}] =
61 | metadata.tool_use_prompt_tokens_details
62 |
63 | assert metadata.traffic_type == :on_demand
64 | end
65 |
66 | test "returns nil when input is nil" do
67 | assert UsageMetadata.from_api(nil) == nil
68 | end
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/docs/20251206/gap_analysis/README.md:
--------------------------------------------------------------------------------
1 | # Gap Analysis: Python genai SDK vs Elixir gemini_ex
2 |
3 | **Analysis Date:** December 6, 2025
4 | **Analysis Method:** 21 parallel subagent deep-dive analysis
5 |
6 | ---
7 |
8 | ## Documents
9 |
10 | | Document | Description |
11 | |----------|-------------|
12 | | [00_executive_summary.md](./00_executive_summary.md) | High-level overview of gaps and recommendations |
13 | | [01_critical_gaps.md](./01_critical_gaps.md) | Detailed analysis of blocking gaps |
14 | | [02_feature_parity_matrix.md](./02_feature_parity_matrix.md) | Complete feature-by-feature comparison |
15 | | [03_implementation_priorities.md](./03_implementation_priorities.md) | Prioritized implementation roadmap |
16 |
17 | ---
18 |
19 | ## Key Findings
20 |
21 | ### Overall Parity Score: 55%
22 |
23 | **Strong Areas:**
24 | - Multi-auth coordination (Vertex AI + Gemini API concurrent)
25 | - SSE streaming (excellent 30-117ms performance)
26 | - Files, Caching, Batches APIs (~85-90% complete)
27 | - Authentication strategies
28 |
29 | **Critical Gaps:**
30 | 1. Live/Real-time API (WebSocket) - ❌ 0%
31 | 2. Tools/Function Calling - ⚠️ 15%
32 | 3. Model Tuning API - ❌ 0%
33 | 4. Grounding/Retrieval - ❌ 0%
34 | 5. System Instruction - ❌ Missing
35 |
36 | ---
37 |
38 | ## Quick Reference
39 |
40 | ### Immediate Actions
41 | 1. Add `system_instruction` to GenerateContentRequest (2-4 hours)
42 | 2. Complete function calling types (1 week)
43 | 3. Implement function execution (1 week)
44 |
45 | ### Production Readiness Path
46 | - Current: Suitable for text generation, file management
47 | - After Tier 2: Suitable for AI agent applications
48 | - After Tier 3: Suitable for real-time/voice applications
49 |
50 | ---
51 |
52 | ## Analysis Methodology
53 |
54 | This analysis was conducted using 21 parallel subagents, each focusing on a specific aspect:
55 |
56 | 1. Client structure and architecture
57 | 2. Models API and content generation
58 | 3. Chat sessions and multi-turn
59 | 4. Authentication strategies
60 | 5. Streaming implementations
61 | 6. Files API
62 | 7. Context caching
63 | 8. Batch processing
64 | 9. Type definitions coverage
65 | 10. Tools and function calling
66 | 11. Safety settings
67 | 12. Embeddings
68 | 13. Live/real-time API
69 | 14. Multimodal support
70 | 15. Grounding and retrieval
71 | 16. Async patterns
72 | 17. Model tuning
73 | 18. Permissions
74 | 19. Pagination
75 | 20. Error handling
76 | 21. Request/response transformation
77 |
78 | Each report provided detailed comparison with specific code references and implementation recommendations.
79 |
--------------------------------------------------------------------------------
/docs/issues/issue-09.json:
--------------------------------------------------------------------------------
1 | {"author":{"id":"MDQ6VXNlcjgwNTY4NTk3","is_bot":false,"login":"yosuaw","name":"Yosua Wijaya"},"body":"As stated in [here](https://ai.google.dev/gemini-api/docs/thinking#set-budget), Gemini API support to specify whether we want to enable, disable, or limit thinking token. I think the current implementation still not supporting that configuration because when I try to run below code, I still get `thoughts_token_count` in the model response.\n\n```\n{:ok, response} = Coordinator.generate_content(\n contents,\n [\n model: \"gemini-2.5-flash\",\n system_instruction: \"\",\n temperature: 0.8,\n top_p: 0.95,\n max_output_tokens: 65_536,\n thinking_config: %{\n thinking_budget: 0\n },\n ]\n )\n```\n\nHere is the output of `response`:\n```\n%Gemini.Types.Response.GenerateContentResponse{\n usage_metadata: %{\n candidates_token_count: 10,\n prompt_token_count: 3,\n prompt_tokens_details: [%{modality: \"TEXT\", token_count: 3}],\n thoughts_token_count: 16,\n total_token_count: 29\n },\n prompt_feedback: nil,\n candidates: [\n %{\n index: 0,\n content: %{\n parts: [%{text: \"Hello there! How can I help you today?\"}],\n role: \"model\"\n },\n finish_reason: \"STOP\"\n }\n ]\n}\n```\nAs you can see, I still get charged from `thoughts_token_count`. Do you planning to support this feature?\n\nThanks","comments":[{"id":"IC_kwDOO3f1O87BFwg4","author":{"login":"nshkrdotcom"},"authorAssociation":"OWNER","body":"@yosuaw Yes, the goal is to continue building out `gemini_ex`. Any chance you could work on it and do a pull request? \n\nSee\nhttps://github.com/nshkrdotcom/gemini_ex/tree/main/oldDocs/docs/spec","createdAt":"2025-08-30T20:01:18Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/nshkrdotcom/gemini_ex/issues/9#issuecomment-3239512120","viewerDidAuthor":true},{"id":"IC_kwDOO3f1O87BRGYE","author":{"login":"yosuaw"},"authorAssociation":"NONE","body":"I’ve created a PR for this. Apologies for the minimal changes and for not adding tests, as I have other work to do","createdAt":"2025-09-01T13:58:27Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/nshkrdotcom/gemini_ex/issues/9#issuecomment-3242485252","viewerDidAuthor":false}],"createdAt":"2025-08-29T10:11:35Z","labels":[],"number":9,"state":"OPEN","title":"Supporting Thinking Budget Config","updatedAt":"2025-09-01T13:58:27Z","url":"https://github.com/nshkrdotcom/gemini_ex/issues/9"}
2 |
--------------------------------------------------------------------------------
/lib/gemini/types/response/batch_state.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.BatchState do
2 | @moduledoc """
3 | Represents the state of an async batch embedding job.
4 |
5 | ## States
6 |
7 | - `:unspecified` - State not specified
8 | - `:pending` - Job queued, not yet processing
9 | - `:processing` - Currently being processed
10 | - `:completed` - Successfully completed
11 | - `:failed` - Processing failed
12 | - `:cancelled` - Job was cancelled
13 |
14 | ## Examples
15 |
16 | # Convert from API response
17 | BatchState.from_string("PROCESSING")
18 | # => :processing
19 |
20 | # Convert to API format
21 | BatchState.to_string(:completed)
22 | # => "COMPLETED"
23 | """
24 |
25 | @type t :: :unspecified | :pending | :processing | :completed | :failed | :cancelled
26 |
27 | @doc """
28 | Converts a string state from the API to an atom.
29 |
30 | Handles both uppercase API format (e.g., "PENDING") and lowercase format.
31 | Unknown states default to `:unspecified`.
32 |
33 | ## Parameters
34 |
35 | - `state_string`: The state string from the API
36 |
37 | ## Returns
38 |
39 | The corresponding atom state
40 |
41 | ## Examples
42 |
43 | BatchState.from_string("PROCESSING")
44 | # => :processing
45 |
46 | BatchState.from_string("pending")
47 | # => :pending
48 |
49 | BatchState.from_string("UNKNOWN")
50 | # => :unspecified
51 | """
52 | @spec from_string(String.t()) :: t()
53 | def from_string(state_string) when is_binary(state_string) do
54 | case String.upcase(state_string) do
55 | "STATE_UNSPECIFIED" -> :unspecified
56 | "PENDING" -> :pending
57 | "PROCESSING" -> :processing
58 | "COMPLETED" -> :completed
59 | "FAILED" -> :failed
60 | "CANCELLED" -> :cancelled
61 | _ -> :unspecified
62 | end
63 | end
64 |
65 | @doc """
66 | Converts an atom state to the API string format.
67 |
68 | ## Parameters
69 |
70 | - `state`: The state atom
71 |
72 | ## Returns
73 |
74 | The API string representation
75 |
76 | ## Examples
77 |
78 | BatchState.to_string(:processing)
79 | # => "PROCESSING"
80 |
81 | BatchState.to_string(:completed)
82 | # => "COMPLETED"
83 | """
84 | @spec to_string(t()) :: String.t()
85 | def to_string(state) when is_atom(state) do
86 | case state do
87 | :unspecified -> "STATE_UNSPECIFIED"
88 | :pending -> "PENDING"
89 | :processing -> "PROCESSING"
90 | :completed -> "COMPLETED"
91 | :failed -> "FAILED"
92 | :cancelled -> "CANCELLED"
93 | end
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/lib/gemini/types/request/batch_embed_contents_request.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Request.BatchEmbedContentsRequest do
2 | @moduledoc """
3 | Request structure for batch embedding multiple content items.
4 |
5 | Allows generating embeddings for multiple text inputs in a single API call,
6 | which is more efficient than individual requests.
7 |
8 | ## Fields
9 |
10 | - `requests`: List of individual embed content requests
11 |
12 | ## Examples
13 |
14 | %BatchEmbedContentsRequest{
15 | requests: [
16 | %EmbedContentRequest{
17 | model: "models/gemini-embedding-001",
18 | content: %Content{parts: [%Part{text: "First text"}]}
19 | },
20 | %EmbedContentRequest{
21 | model: "models/gemini-embedding-001",
22 | content: %Content{parts: [%Part{text: "Second text"}]}
23 | }
24 | ]
25 | }
26 | """
27 |
28 | alias Gemini.Types.Request.EmbedContentRequest
29 |
30 | @enforce_keys [:requests]
31 | defstruct [:requests]
32 |
33 | @type t :: %__MODULE__{
34 | requests: [EmbedContentRequest.t()]
35 | }
36 |
37 | @doc """
38 | Creates a new batch embedding request from a list of texts.
39 |
40 | Uses auth-aware embedding model selection:
41 | - **Gemini API**: `gemini-embedding-001` with taskType parameter
42 | - **Vertex AI**: `embeddinggemma` with prompt prefix formatting
43 |
44 | ## Parameters
45 |
46 | - `texts`: List of text strings to embed
47 | - `opts`: Optional keyword list of options to apply to all requests
48 | - `:model`: Model to use (default: auto-detected based on auth)
49 | - `:task_type`: Task type for optimized embeddings
50 | - `:output_dimensionality`: Dimension reduction
51 |
52 | ## Examples
53 |
54 | BatchEmbedContentsRequest.new([
55 | "What is AI?",
56 | "How does machine learning work?",
57 | "Explain neural networks"
58 | ])
59 |
60 | BatchEmbedContentsRequest.new(
61 | ["Doc 1", "Doc 2"],
62 | task_type: :retrieval_document,
63 | output_dimensionality: 256
64 | )
65 | """
66 | @spec new([String.t()], keyword()) :: t()
67 | def new(texts, opts \\ []) when is_list(texts) do
68 | requests = Enum.map(texts, &EmbedContentRequest.new(&1, opts))
69 |
70 | %__MODULE__{
71 | requests: requests
72 | }
73 | end
74 |
75 | @doc """
76 | Converts the batch request to API-compatible map format.
77 | """
78 | @spec to_api_map(t()) :: map()
79 | def to_api_map(%__MODULE__{requests: requests}) do
80 | %{
81 | "requests" => Enum.map(requests, &EmbedContentRequest.to_api_map/1)
82 | }
83 | end
84 | end
85 |
--------------------------------------------------------------------------------
/docs/20251203/gemini_rate_limits/adrs/ADR-0001-rate-limit-manager.md:
--------------------------------------------------------------------------------
1 | # ADR 0001: Gemini rate-limit manager in gemini_ex
2 |
3 | - Status: Accepted
4 | - Date: 2025-12-04
5 |
6 | ## Context
7 | - Gemini returns 429 with structured quota details (quotaMetric, quotaId, quotaDimensions, quotaValue, RetryInfo.retryDelay) but no remaining counters.
8 | - Lumainus fires multiple section requests in parallel and regularly trips the per-minute token cap, causing failed sections.
9 | - Rate-limit handling belongs inside gemini_ex so every consumer benefits without bespoke app code.
10 |
11 | ## Decision
12 | - Add a first-class `GeminiEx.RateLimiter` that wraps outbound requests.
13 | - Track per-model/location/metric state: sliding windows of token usage (input/output) and `retry_until` timestamps derived from 429 RetryInfo.
14 | - Before sending, consult `retry_until`; if in the future, block/queue until then (or return a structured rate-limit error when `non_blocking: true` is set).
15 | - After responses, record usage to refine the local budget model.
16 | - Store state in ETS/Agent keyed by `{model, location, metric}` for lightweight, shared visibility across processes.
17 | - Enabled by default (breaking change vs prior “fire and pray”); provide `disable_rate_limiter: true` opt-out and document in README/CHANGELOG/migration notes.
18 | - Concurrency defaults are conservative (e.g., 4 per model) but configurable; `nil`/0 disables concurrency gating.
19 | - Optional adaptive mode: start low, raise concurrency until 429 is observed, then back off; cap with a configured ceiling. Provide simple profiles (`:dev | :prod | :custom`) to seed defaults.
20 | - Scope: rate limiter wraps request submission only; once a stream/response is open, it does not interfere.
21 | - Testing strategy (Supertester-first, no sleeps):
22 | - Use a local fake Gemini endpoint (Bypass/Plug) that can emit 200, 429 with RetryInfo body, and 5xx.
23 | - Assert retry_until state updates, gating behavior, structured errors, and telemetry events.
24 | - Concurrency/adaptive: set max_concurrency=1 in tests, fire N requests, assert serialized hits; for adaptive, have fake flip to 429 after K hits and back to 200 to verify backoff/raise.
25 | - Token budgeting: stub usage in responses, assert preflight blocks when over budget.
26 | - non_blocking: ensure requests return immediately with {:rate_limited, retry_at} when gating is active.
27 | - Keep all tests isolated/async via Supertester; no Process.sleep.
28 |
29 | ## Consequences
30 | - Requests will be paced automatically; callers see fewer 429s and cleaner error messaging.
31 | - A blocked/queued request may wait up to the current `retry_until`, so callers should expect possible delays.
32 | - Future: this state enables better scheduling (e.g., prioritization) without API changes.
33 |
--------------------------------------------------------------------------------
/lib/gemini/types/common/content.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Content do
2 | @moduledoc """
3 | Content type for Gemini API requests and responses.
4 | """
5 |
6 | use TypedStruct
7 |
8 | @derive Jason.Encoder
9 | typedstruct do
10 | field(:role, String.t(), default: "user")
11 | field(:parts, [Gemini.Types.Part.t()], default: [])
12 | end
13 |
14 | @typedoc "The role of the content creator."
15 | @type role :: String.t()
16 |
17 | @typedoc "Ordered parts that constitute a single message."
18 | @type parts :: [Gemini.Types.Part.t()]
19 |
20 | @doc """
21 | Create content with text.
22 | """
23 | @spec text(String.t(), String.t()) :: t()
24 | def text(text, role \\ "user") do
25 | %__MODULE__{
26 | role: role,
27 | parts: [Gemini.Types.Part.text(text)]
28 | }
29 | end
30 |
31 | @doc """
32 | Create content with text and image.
33 | """
34 | @spec multimodal(String.t(), String.t(), String.t(), String.t()) :: t()
35 | def multimodal(text, image_data, mime_type, role \\ "user") do
36 | %__MODULE__{
37 | role: role,
38 | parts: [
39 | Gemini.Types.Part.text(text),
40 | Gemini.Types.Part.inline_data(image_data, mime_type)
41 | ]
42 | }
43 | end
44 |
45 | @doc """
46 | Create content with an image from a file path.
47 | """
48 | @spec image(String.t(), String.t()) :: t()
49 | def image(path, role \\ "user") do
50 | %__MODULE__{
51 | role: role,
52 | parts: [Gemini.Types.Part.file(path)]
53 | }
54 | end
55 |
56 | @doc """
57 | Create content from tool results for function response.
58 |
59 | Takes a list of validated ToolResult structs and transforms them into
60 | a single Content struct with role "tool" containing functionResponse parts.
61 |
62 | ## Parameters
63 | - `results` - List of Altar.ADM.ToolResult.t() structs
64 |
65 | ## Returns
66 | - Content struct with role "tool" and functionResponse parts
67 |
68 | ## Examples
69 |
70 | iex> results = [%Altar.ADM.ToolResult{call_id: "call_123", content: "result"}]
71 | iex> Gemini.Types.Content.from_tool_results(results)
72 | %Gemini.Types.Content{
73 | role: "tool",
74 | parts: [%{functionResponse: %{name: "call_123", response: %{content: "result"}}}]
75 | }
76 |
77 | """
78 | @spec from_tool_results([Altar.ADM.ToolResult.t()]) :: t()
79 | def from_tool_results(results) when is_list(results) do
80 | parts =
81 | Enum.map(results, fn result ->
82 | %{
83 | "functionResponse" => %{
84 | "name" => result.call_id,
85 | "response" => %{"content" => result.content}
86 | }
87 | }
88 | end)
89 |
90 | %__MODULE__{
91 | role: "tool",
92 | parts: parts
93 | }
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/oldDocs/docs/spec/GEMINI-DOCS-03-LIBRARIES.md:
--------------------------------------------------------------------------------
1 | # Gemini API Libraries
2 |
3 | This page provides information on downloading and installing the latest libraries for the Gemini API. If you're new to the Gemini API, get started with the API quickstart.
4 |
5 | **Important note about our new libraries**
6 |
7 | We've recently launched a new set of libraries that provide a more consistent and streamlined experience for accessing Google's generative AI models across different Google services.
8 |
9 | ## Key Library Updates
10 |
11 | | Language | Old library | New library (Recommended) |
12 | | :------------------- | :------------------------- | :-------------------------- |
13 | | Python | `google-generativeai` | `google-genai` |
14 | | JavaScript and TypeScript | `@google/generative-ai` | `@google/genai`\
Currently in Preview |
15 | | Go | `google.golang.org/generative-ai` | `google.golang.org/genai` |
16 |
17 | We strongly encourage all users of the previous libraries to migrate to the new libraries. Despite the JavaScript/TypeScript library being in Preview, we still recommend that you start migrating, as long as you are comfortable with the caveats listed in the JavaScript/TypeScript section.
18 |
19 | ## Python
20 |
21 | You can install our Python library by running:
22 |
23 | ```bash
24 | pip install google-genai
25 | ```
26 |
27 | ## JavaScript and TypeScript
28 |
29 | You can install our JavaScript and TypeScript library by running:
30 |
31 | ```bash
32 | npm install @google/genai
33 | ```
34 |
35 | The new JavaScript/TypeScript library is currently in **preview**, which means it may not be feature complete and that we may need to introduce breaking changes.
36 |
37 | However, we **highly recommend** you start using the **new SDK** over the **previous, deprecated version**, as long as you are comfortable with these caveats. We are actively working towards a GA (General Availability) release for this library.
38 |
39 | ### API keys in client-side applications
40 |
41 | **WARNING:** No matter which library you're using, it is **unsafe** to insert your API key into client-side JavaScript or TypeScript code. Use server-side deployments for accessing Gemini API in production.
42 |
43 | ## Go
44 |
45 | You can install our Go library by running:
46 |
47 | ```bash
48 | go get google.golang.org/genai
49 | ```
50 |
51 | ## Previous libraries and SDKs
52 |
53 | The following is a set of our previous SDK's which are no longer being actively developed, you can switch to the updated Google Gen AI SDK by using our migration guide:
54 |
55 | * Previous Python library
56 | * Previous Node.js library
57 | * Previous Go library
58 | * Previous Dart and Flutter library
59 | * Previous Swift library
60 | * Previous Android library
61 |
--------------------------------------------------------------------------------
/lib/gemini/types/response/embed_content_batch_output.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.EmbedContentBatchOutput do
2 | @moduledoc """
3 | Output of an async batch embedding job.
4 |
5 | This is a union type - exactly ONE of the fields will be set.
6 |
7 | ## Union Type - ONE will be set:
8 |
9 | - `responses_file`: File ID containing JSONL responses (for file-based output)
10 | - `inlined_responses`: Direct inline responses (for inline output)
11 |
12 | ## Fields
13 |
14 | - `responses_file`: GCS file containing batch results
15 | - `inlined_responses`: Container with inline response data
16 |
17 | ## Examples
18 |
19 | # File-based output
20 | %EmbedContentBatchOutput{
21 | responses_file: "gs://bucket/outputs/batch-001-results.jsonl",
22 | inlined_responses: nil
23 | }
24 |
25 | # Inline output
26 | %EmbedContentBatchOutput{
27 | responses_file: nil,
28 | inlined_responses: %InlinedEmbedContentResponses{...}
29 | }
30 | """
31 |
32 | alias Gemini.Types.Response.InlinedEmbedContentResponses
33 |
34 | defstruct [:responses_file, :inlined_responses]
35 |
36 | @type t :: %__MODULE__{
37 | responses_file: String.t() | nil,
38 | inlined_responses: InlinedEmbedContentResponses.t() | nil
39 | }
40 |
41 | @doc """
42 | Creates batch output from API response data.
43 |
44 | ## Parameters
45 |
46 | - `data`: Map containing the API response
47 |
48 | ## Examples
49 |
50 | EmbedContentBatchOutput.from_api_response(%{
51 | "responsesFile" => "gs://bucket/results.jsonl"
52 | })
53 | """
54 | @spec from_api_response(map()) :: t()
55 | def from_api_response(data) when is_map(data) do
56 | %__MODULE__{
57 | responses_file: data["responsesFile"],
58 | inlined_responses: parse_inlined_responses(data)
59 | }
60 | end
61 |
62 | @doc """
63 | Checks if the output is file-based.
64 |
65 | ## Examples
66 |
67 | EmbedContentBatchOutput.is_file_based?(output)
68 | # => true
69 | """
70 | @spec is_file_based?(t()) :: boolean()
71 | def is_file_based?(%__MODULE__{responses_file: file}) when is_binary(file), do: true
72 | def is_file_based?(_), do: false
73 |
74 | @doc """
75 | Checks if the output is inline.
76 |
77 | ## Examples
78 |
79 | EmbedContentBatchOutput.is_inline?(output)
80 | # => false
81 | """
82 | @spec is_inline?(t()) :: boolean()
83 | def is_inline?(%__MODULE__{inlined_responses: %InlinedEmbedContentResponses{}}), do: true
84 | def is_inline?(_), do: false
85 |
86 | # Private helpers
87 |
88 | defp parse_inlined_responses(%{"inlinedResponses" => _} = data) do
89 | InlinedEmbedContentResponses.from_api_response(data)
90 | end
91 |
92 | defp parse_inlined_responses(_), do: nil
93 | end
94 |
--------------------------------------------------------------------------------
/lib/gemini/client.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Client do
2 | @moduledoc """
3 | Main client module that delegates to the appropriate HTTP client implementation.
4 |
5 | This module provides a unified interface for making HTTP requests to the Gemini API,
6 | abstracting away the specific implementation details of the underlying HTTP client.
7 | """
8 |
9 | alias Gemini.Client.HTTP
10 |
11 | @doc """
12 | Make a GET request using the configured authentication.
13 |
14 | ## Parameters
15 | - `path` - The API path to request
16 | - `opts` - Optional keyword list of request options
17 |
18 | ## Returns
19 | - `{:ok, response}` - Successful response
20 | - `{:error, Error.t()}` - Error details
21 | """
22 | defdelegate get(path, opts \\ []), to: HTTP
23 |
24 | @doc """
25 | Make a POST request using the configured authentication.
26 |
27 | ## Parameters
28 | - `path` - The API path to request
29 | - `body` - The request body (will be JSON encoded)
30 | - `opts` - Optional keyword list of request options
31 |
32 | ## Returns
33 | - `{:ok, response}` - Successful response
34 | - `{:error, Error.t()}` - Error details
35 | """
36 | defdelegate post(path, body, opts \\ []), to: HTTP
37 |
38 | @doc """
39 | Make an authenticated HTTP request.
40 |
41 | ## Parameters
42 | - `method` - HTTP method (:get, :post, etc.)
43 | - `path` - The API path to request
44 | - `body` - The request body (nil for GET requests)
45 | - `auth_config` - Authentication configuration
46 | - `opts` - Optional keyword list of request options
47 |
48 | ## Returns
49 | - `{:ok, response}` - Successful response
50 | - `{:error, Error.t()}` - Error details
51 | """
52 | defdelegate request(method, path, body, auth_config, opts \\ []), to: HTTP
53 |
54 | @doc """
55 | Stream a POST request for Server-Sent Events using configured authentication.
56 |
57 | ## Parameters
58 | - `path` - The API path to request
59 | - `body` - The request body (will be JSON encoded)
60 | - `opts` - Optional keyword list of request options
61 |
62 | ## Returns
63 | - `{:ok, events}` - Successful stream response with parsed events
64 | - `{:error, Error.t()}` - Error details
65 | """
66 | defdelegate stream_post(path, body, opts \\ []), to: HTTP
67 |
68 | @doc """
69 | Stream a POST request with specific authentication configuration.
70 |
71 | ## Parameters
72 | - `path` - The API path to request
73 | - `body` - The request body (will be JSON encoded)
74 | - `auth_config` - Authentication configuration
75 | - `opts` - Optional keyword list of request options
76 |
77 | ## Returns
78 | - `{:ok, events}` - Successful stream response with parsed events
79 | - `{:error, Error.t()}` - Error details
80 | """
81 | defdelegate stream_post_with_auth(path, body, auth_config, opts \\ []), to: HTTP
82 | end
83 |
--------------------------------------------------------------------------------
/test/gemini/apis/coordinator_response_parsing_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.APIs.CoordinatorResponseParsingTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.APIs.Coordinator
5 | alias Gemini.Types.Response.GenerateContentResponse
6 |
7 | test "__test_parse_generate_response__/1 populates new fields" do
8 | api_response = %{
9 | "responseId" => "resp-123",
10 | "modelVersion" => "gemini-2.0-flash-exp-001",
11 | "createTime" => "2025-12-05T10:15:30Z",
12 | "usageMetadata" => %{
13 | "totalTokenCount" => 20,
14 | "thoughtsTokenCount" => 5
15 | },
16 | "promptFeedback" => %{
17 | "blockReason" => "SAFETY",
18 | "blockReasonMessage" => "Blocked"
19 | },
20 | "candidates" => [
21 | %{
22 | "index" => 0,
23 | "finishReason" => "STOP",
24 | "finishMessage" => "done",
25 | "avgLogprobs" => -0.12,
26 | "content" => %{
27 | "role" => "model",
28 | "parts" => [
29 | %{
30 | "text" => "Hi",
31 | "thought" => true,
32 | "fileData" => %{"fileUri" => "gs://bucket/audio.mp3", "mimeType" => "audio/mpeg"},
33 | "functionResponse" => %{"name" => "lookup", "response" => %{"result" => "ok"}}
34 | }
35 | ]
36 | },
37 | "safetyRatings" => [
38 | %{
39 | "category" => "HARM_CATEGORY_HATE_SPEECH",
40 | "probability" => "LOW",
41 | "probabilityScore" => 0.1,
42 | "severity" => "harm_severity_low",
43 | "severityScore" => 0.05
44 | }
45 | ]
46 | }
47 | ]
48 | }
49 |
50 | assert {:ok, %GenerateContentResponse{} = response} =
51 | Coordinator.__test_parse_generate_response__(api_response)
52 |
53 | assert response.response_id == "resp-123"
54 | assert response.model_version == "gemini-2.0-flash-exp-001"
55 | assert %DateTime{} = response.create_time
56 | assert response.usage_metadata.thoughts_token_count == 5
57 |
58 | candidate = hd(response.candidates)
59 | assert candidate.finish_message == "done"
60 | assert candidate.avg_logprobs == -0.12
61 | assert candidate.index == 0
62 | assert candidate.content.parts |> hd() |> Map.get(:thought) == true
63 |
64 | part = hd(candidate.content.parts)
65 | assert part.file_data.file_uri == "gs://bucket/audio.mp3"
66 | assert part.function_response.name == "lookup"
67 |
68 | rating = hd(candidate.safety_ratings)
69 | assert rating.probability_score == 0.1
70 | assert rating.severity == "harm_severity_low"
71 | assert rating.severity_score == 0.05
72 |
73 | assert response.prompt_feedback.block_reason_message == "Blocked"
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/test/gemini_test.exs:
--------------------------------------------------------------------------------
1 | defmodule GeminiTest do
2 | use ExUnit.Case
3 | doctest Gemini
4 |
5 | alias Gemini.Types.{Content, Part, GenerationConfig, SafetySetting}
6 |
7 | describe "content creation" do
8 | test "creates text content" do
9 | content = Content.text("Hello, world!")
10 |
11 | assert %Content{
12 | parts: [%Part{text: "Hello, world!"}],
13 | role: "user"
14 | } = content
15 | end
16 |
17 | test "creates multimodal content" do
18 | # Test with a temporary file
19 | temp_path = "/tmp/test_image.txt"
20 | File.write!(temp_path, "fake image data")
21 |
22 | content = Content.image(temp_path)
23 |
24 | assert %Content{
25 | parts: [%Part{inline_data: %Gemini.Types.Blob{}}],
26 | role: "user"
27 | } = content
28 |
29 | File.rm!(temp_path)
30 | end
31 | end
32 |
33 | describe "generation config" do
34 | test "creates creative config" do
35 | config = GenerationConfig.creative()
36 |
37 | assert %GenerationConfig{
38 | temperature: 0.9,
39 | top_p: 1.0,
40 | top_k: 40
41 | } = config
42 | end
43 |
44 | test "creates deterministic config" do
45 | config = GenerationConfig.deterministic()
46 |
47 | assert %GenerationConfig{
48 | temperature: +0.0,
49 | candidate_count: 1
50 | } = config
51 | end
52 | end
53 |
54 | describe "safety settings" do
55 | test "creates default safety settings" do
56 | settings = SafetySetting.defaults()
57 |
58 | assert length(settings) == 4
59 |
60 | assert Enum.all?(settings, fn setting ->
61 | setting.threshold == :block_medium_and_above
62 | end)
63 | end
64 |
65 | test "creates permissive safety settings" do
66 | settings = SafetySetting.permissive()
67 |
68 | assert length(settings) == 4
69 |
70 | assert Enum.all?(settings, fn setting ->
71 | setting.threshold == :block_only_high
72 | end)
73 | end
74 | end
75 |
76 | # These tests require an API key and internet connection
77 | # Uncomment and set GEMINI_API_KEY to run them
78 |
79 | # describe "API integration" do
80 | # @tag :integration
81 | # test "lists models" do
82 | # {:ok, response} = Gemini.list_models()
83 | # assert length(response.models) > 0
84 | # end
85 |
86 | # @tag :integration
87 | # test "generates text content" do
88 | # {:ok, text} = Gemini.text("Say hello")
89 | # assert is_binary(text)
90 | # assert String.length(text) > 0
91 | # end
92 |
93 | # @tag :integration
94 | # test "counts tokens" do
95 | # {:ok, response} = Gemini.count_tokens("Hello, world!")
96 | # assert response.total_tokens > 0
97 | # end
98 | # end
99 | end
100 |
--------------------------------------------------------------------------------
/docs/issues/issue-11.json:
--------------------------------------------------------------------------------
1 | {"author":{"id":"MDQ6VXNlcjI2Mjk=","is_bot":false,"login":"jaimeiniesta","name":"Jaime Iniesta"},"body":"Hi, thanks for putting together this awesome library!\n\nI'm trying to get image recognition to work, but the example from https://hexdocs.pm/gemini_ex/Gemini.html#module-multimodal-content doesn't seem to work.\n\nHere's an example:\n\n```elixir\ndefmodule GeminiExGoogleVision do\n require Logger\n\n @img_url \"https://png.pngtree.com/png-clipart/20230804/original/pngtree-example-sample-grungy-stamp-vector-picture-image_9574934.png\"\n\n def test(url \\\\ @img_url) do\n {:ok, %{content_type: _content_type, data: data}} = download_image(url) |> IO.inspect(label: \"download_image\")\n\n content = [\n %{type: \"text\", text: \"Describe this image. If you can't see the image, just say you can't.\"},\n %{type: \"image\", source: %{type: \"base64\", data: Base.encode64(data)}}\n ]\n\n Gemini.generate(content)\n end\n\n defp download_image(url, _max_size_bytes \\\\ 20 * 1024 * 1024) do\n Logger.debug(\"[#{__MODULE__}] downloading image...\")\n\n with %Req.Response{status: 200, body: body, headers: headers} <- Req.get!(url),\n {:ok, content_type} <- get_content_type(headers) do\n Logger.debug(\"[#{__MODULE__}] finished downloading image...\")\n\n {:ok, %{content_type: content_type, data: body}}\n else\n {:error, :cant_get_content_type} ->\n {:error, :cant_get_content_type}\n\n %Req.Response{status: status} when status != 200 ->\n {:erorr, :cant_download_image}\n end\n end\n\n defp get_content_type(headers) do\n headers\n |> Enum.find(fn {key, _} -> String.downcase(key) == \"content-type\" end)\n |> case do\n {_, value} -> {:ok, hd(value)}\n nil -> {:error, :cant_get_content_type}\n end\n end\nend\n```\n\nBy running `GeminiExGoogleVision.test()` it will download the image and include it in the request as inline data.\n\nBut this fails with:\n\n```\nThe following arguments were given to Gemini.APIs.Coordinator.format_content/1:\n \n # 1\n %{\n type: \"text\",\n text: \"Describe this image. If you can't see the image, just say you can't.\"\n }\n \n Attempted function clauses (showing 1 out of 1):\n \n defp format_content(%Gemini.Types.Content{role: role, parts: parts})\n \n (gemini_ex 0.2.1) lib/gemini/apis/coordinator.ex:447: Gemini.APIs.Coordinator.format_content/1\n (elixir 1.18.4) lib/enum.ex:1714: Enum.\"-map/2-lists^map/1-1-\"/2\n (gemini_ex 0.2.1) lib/gemini/apis/coordinator.ex:411: Gemini.APIs.Coordinator.build_generate_request/2\n (gemini_ex 0.2.1) lib/gemini/apis/coordinator.ex:82: Gemini.APIs.Coordinator.generate_content/2\n\n```\n\nWhat's the proper way of doing this?\n\nAlso, shouldn't we pass the `content_type` along with the data?","comments":[],"createdAt":"2025-10-06T12:07:22Z","labels":[],"number":11,"state":"OPEN","title":"Multimodal example not working","updatedAt":"2025-10-06T12:07:22Z","url":"https://github.com/nshkrdotcom/gemini_ex/issues/11"}
2 |
--------------------------------------------------------------------------------
/docs/20251204/context-caching-enhancement/IMPLEMENTATION_PLAN_QA.md:
--------------------------------------------------------------------------------
1 | # Context Caching Plan QA
2 |
3 | - Quick take: Missing-features table is directionally right, but the current implementation has a few additional gaps/bugs that the plan does not call out.
4 |
5 | ## Confirmed
6 | - CRUD helpers exist in `lib/gemini/apis/context_cache.ex` and responses surface `cachedContentTokenCount` via `UsageMetadata` (`lib/gemini/types/response/generate_content_response.ex:117-130`).
7 | - Generate requests already accept `cached_content` (`lib/gemini/apis/coordinator.ex:788-858`).
8 | - PATCH/DELETE plumbing is present in `lib/gemini/client/http.ex`.
9 |
10 | ## Corrections / Issues
11 | - Vertex AI paths are currently broken: non-model requests are just appended to the Vertex base URL (`lib/gemini/client/http.ex:266-285`), so `create/list/get/update/delete` hit `https://-aiplatform.googleapis.com/v1/cachedContents` without the required `projects/{project}/locations/{location}` segment from `Gemini.Auth.VertexStrategy` (`lib/gemini/auth/vertex_strategy.ex:86-115`). The existing code is Gemini-only until resource-name expansion is added.
12 | - `create/2` only matches lists (`lib/gemini/apis/context_cache.ex:80-110`); the README example uses a bare string and will raise a function-clause error (`README.md:236-246`).
13 | - Model handling needs normalization: `full_model_name = "models/#{model}"` double-prefixes if callers pass `models/...` or a Vertex resource, and the default model is the alias `gemini-flash-lite-latest` (not an explicit cache-supported version) (`lib/gemini/apis/context_cache.ex:87-103`, `lib/gemini/config.ex:15-44`).
14 | - Content formatting is narrow: `format_parts/1` only converts text/inlineData and leaves tool/function/response/thought/file parts in snake_case structs that won’t serialize to the cache API (`lib/gemini/apis/context_cache.ex:292-309`). Parity work needs to cover those shapes, not just `fileData`.
15 | - Usage metadata normalization only keeps total/cached token counts; any Vertex-only fields called out in the plan do not exist yet (`lib/gemini/apis/context_cache.ex:311-329`).
16 | - Tests don’t exercise payload shaping or name normalization; unit tests only check argument validation and don’t mock HTTP, so coverage for the proposed features is essentially absent (`test/gemini/apis/context_cache_test.exs`).
17 | - Runtime config helper mismatch: `Gemini.configure/2` writes to the `:gemini` app env (`lib/gemini.ex:217-220`), while `Gemini.Config.auth_config/0` reads `:gemini_ex` (`lib/gemini/config.ex:124-170`), so configure/2 won’t affect cache calls unless env vars are set.
18 |
19 | ## Additional Gaps Not Listed in the Plan
20 | - No per-request auth override for cache endpoints: `Gemini.Client.HTTP` always uses the global config and ignores `:auth`, so multi-auth parity is incomplete.
21 | - Ergonomics differ from Python: there’s no single-content overload (everything must be wrapped in a list), and content formatting is not shared with the generate pipeline.
22 |
23 | ## Test Status
24 | - Not run (review only).
25 |
--------------------------------------------------------------------------------
/test/gemini/types/tool_serialization_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.ToolSerializationTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.ToolSerialization
5 | alias Altar.ADM.{FunctionDeclaration, ToolConfig}
6 |
7 | describe "to_api_tool_list/1" do
8 | test "transforms FunctionDeclaration into API tool list with camelCase keys and schema" do
9 | decl = %FunctionDeclaration{
10 | name: "get_weather",
11 | description: "Gets the weather",
12 | parameters: %{
13 | type: "OBJECT",
14 | properties: %{
15 | location: %{type: "STRING", description: "City, State"},
16 | days: %{type: "INTEGER"},
17 | units: %{type: "STRING", enum: ["celsius", "fahrenheit"]}
18 | },
19 | required: ["location"]
20 | }
21 | }
22 |
23 | [tool] = ToolSerialization.to_api_tool_list([decl])
24 |
25 | assert %{"functionDeclarations" => [fd]} = tool
26 | assert fd["name"] == "get_weather"
27 | assert fd["description"] == "Gets the weather"
28 |
29 | # parameters should have camelCase keys and preserve values
30 | params = fd["parameters"]
31 | assert Map.has_key?(params, "type")
32 | assert Map.has_key?(params, "properties")
33 | assert Map.has_key?(params, "required")
34 |
35 | # nested keys inside properties should also be converted
36 | props = params["properties"]
37 | # property names are user-defined keys and should remain as-is (strings)
38 | assert %{
39 | "location" => %{"type" => "STRING", "description" => "City, State"},
40 | "days" => %{"type" => "INTEGER"},
41 | "units" => %{"type" => "STRING", "enum" => ["celsius", "fahrenheit"]}
42 | } = props
43 |
44 | # required remains as-is
45 | assert params["required"] == ["location"]
46 | end
47 |
48 | test "returns empty list when no declarations provided" do
49 | assert ToolSerialization.to_api_tool_list([]) == []
50 | end
51 | end
52 |
53 | describe "to_api_tool_config/1" do
54 | test "serializes :auto mode without allowedFunctionNames when empty" do
55 | cfg = %ToolConfig{mode: :auto, function_names: []}
56 | assert %{functionCallingConfig: %{mode: "AUTO"}} = ToolSerialization.to_api_tool_config(cfg)
57 | end
58 |
59 | test "serializes :any mode with allowedFunctionNames when provided" do
60 | cfg = %ToolConfig{mode: :any, function_names: ["get_weather", "get_time"]}
61 |
62 | assert %{
63 | functionCallingConfig: %{
64 | mode: "ANY",
65 | allowedFunctionNames: ["get_weather", "get_time"]
66 | }
67 | } = ToolSerialization.to_api_tool_config(cfg)
68 | end
69 |
70 | test "serializes :none mode" do
71 | cfg = %ToolConfig{mode: :none, function_names: []}
72 | assert %{functionCallingConfig: %{mode: "NONE"}} = ToolSerialization.to_api_tool_config(cfg)
73 | end
74 | end
75 | end
76 |
--------------------------------------------------------------------------------
/lib/gemini/types/response/inlined_embed_content_response.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.InlinedEmbedContentResponse do
2 | @moduledoc """
3 | Response for a single request within an async batch.
4 |
5 | This is a union type - exactly ONE of `response` or `error` will be set.
6 |
7 | ## Union Type - ONE will be set:
8 |
9 | - `response`: Successful EmbedContentResponse
10 | - `error`: Error status if request failed
11 |
12 | ## Fields
13 |
14 | - `metadata`: Optional metadata from the request
15 | - `response`: Successful embedding response (if successful)
16 | - `error`: Error details (if failed)
17 |
18 | ## Examples
19 |
20 | # Successful response
21 | %InlinedEmbedContentResponse{
22 | metadata: %{"id" => "123"},
23 | response: %EmbedContentResponse{...},
24 | error: nil
25 | }
26 |
27 | # Failed response
28 | %InlinedEmbedContentResponse{
29 | metadata: %{"id" => "456"},
30 | response: nil,
31 | error: %{"code" => 400, "message" => "Invalid input"}
32 | }
33 | """
34 |
35 | alias Gemini.Types.Response.EmbedContentResponse
36 |
37 | defstruct [:metadata, :response, :error]
38 |
39 | @type t :: %__MODULE__{
40 | metadata: map() | nil,
41 | response: EmbedContentResponse.t() | nil,
42 | error: map() | nil
43 | }
44 |
45 | @doc """
46 | Creates an inlined response from API response data.
47 |
48 | ## Parameters
49 |
50 | - `data`: Map containing the API response
51 |
52 | ## Examples
53 |
54 | InlinedEmbedContentResponse.from_api_response(%{
55 | "metadata" => %{"id" => "123"},
56 | "response" => %{"embedding" => %{"values" => [...]}}
57 | })
58 | """
59 | @spec from_api_response(map()) :: t()
60 | def from_api_response(data) when is_map(data) do
61 | %__MODULE__{
62 | metadata: data["metadata"],
63 | response: parse_response(data["response"]),
64 | error: data["error"]
65 | }
66 | end
67 |
68 | @doc """
69 | Checks if the inlined response is successful.
70 |
71 | ## Examples
72 |
73 | InlinedEmbedContentResponse.is_success?(response)
74 | # => true
75 | """
76 | @spec is_success?(t()) :: boolean()
77 | def is_success?(%__MODULE__{response: %EmbedContentResponse{}, error: nil}), do: true
78 | def is_success?(_), do: false
79 |
80 | @doc """
81 | Checks if the inlined response is an error.
82 |
83 | ## Examples
84 |
85 | InlinedEmbedContentResponse.is_error?(response)
86 | # => false
87 | """
88 | @spec is_error?(t()) :: boolean()
89 | def is_error?(%__MODULE__{response: nil, error: error}) when is_map(error), do: true
90 | def is_error?(_), do: false
91 |
92 | # Private helpers
93 |
94 | defp parse_response(nil), do: nil
95 |
96 | defp parse_response(response_data) when is_map(response_data) do
97 | EmbedContentResponse.from_api_response(response_data)
98 | end
99 | end
100 |
--------------------------------------------------------------------------------
/test/fixtures/multimodal/README.md:
--------------------------------------------------------------------------------
1 | # Multimodal Test Fixtures
2 |
3 | **Purpose:** Minimal test images for live API testing of multimodal content handling
4 | **Created:** 2025-10-07
5 | **Total Size:** ~300 bytes
6 |
7 | ---
8 |
9 | ## Test Images
10 |
11 | ### test_image_1x1.png (67 bytes)
12 | - **Format:** PNG
13 | - **Dimensions:** 1x1 pixel
14 | - **Color:** Transparent
15 | - **Purpose:** Minimal valid PNG for testing auto-detection and processing
16 |
17 | ### test_image_1x1.jpg (68 bytes)
18 | - **Format:** JPEG
19 | - **Dimensions:** 1x1 pixel
20 | - **Color:** White
21 | - **Purpose:** Minimal valid JPEG for testing format detection
22 |
23 | ### test_image_2x2_colored.png (79 bytes)
24 | - **Format:** PNG
25 | - **Dimensions:** 2x2 pixels
26 | - **Colors:** Red, Green, Blue, White
27 | - **Purpose:** Color detection and description testing
28 |
29 | ---
30 |
31 | ## Usage
32 |
33 | ### In Live API Tests
34 |
35 | ```elixir
36 | @fixtures_dir Path.join([__DIR__, "..", "..", "fixtures", "multimodal"])
37 |
38 | test "processes real PNG image" do
39 | image_path = Path.join(@fixtures_dir, "test_image_1x1.png")
40 | {:ok, image_data} = File.read(image_path)
41 |
42 | content = [
43 | %{type: "text", text: "What format is this image?"},
44 | %{type: "image", source: %{type: "base64", data: Base.encode64(image_data)}}
45 | ]
46 |
47 | {:ok, response} = Gemini.generate(content, model: "gemini-2.5-flash")
48 | end
49 | ```
50 |
51 | ### In Examples
52 |
53 | ```elixir
54 | # examples/multimodal_demo.exs
55 | image_path = "test/fixtures/multimodal/test_image_1x1.png"
56 | {:ok, image_data} = File.read(image_path)
57 |
58 | Gemini.generate([
59 | %{type: "text", text: "Describe this"},
60 | %{type: "image", source: %{type: "base64", data: Base.encode64(image_data)}}
61 | ])
62 | ```
63 |
64 | ---
65 |
66 | ## Regenerating Images
67 |
68 | If these files are lost or corrupted, regenerate with:
69 |
70 | ```bash
71 | elixir test/fixtures/multimodal/create_test_images.exs
72 | ```
73 |
74 | ---
75 |
76 | ## Why Minimal Images?
77 |
78 | 1. **Small repo size** - Only ~300 bytes total
79 | 2. **Fast to load** - No I/O overhead in tests
80 | 3. **Deterministic** - Same images every test run
81 | 4. **Valid format** - Real PNG/JPEG headers for proper testing
82 | 5. **Git-friendly** - Binary but tiny, minimal repo pollution
83 |
84 | ---
85 |
86 | ## Image Specifications
87 |
88 | ### PNG Format
89 | - Magic bytes: `0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A`
90 | - IHDR chunk: Specifies dimensions and color type
91 | - IDAT chunk: Compressed pixel data (zlib)
92 | - IEND chunk: End marker
93 |
94 | ### JPEG Format
95 | - SOI marker: `0xFF 0xD8`
96 | - APP0 (JFIF): File format info
97 | - SOF0: Frame parameters
98 | - DHT: Huffman tables
99 | - SOS: Scan data
100 | - EOI marker: `0xFF 0xD9`
101 |
102 | ---
103 |
104 | **Maintained By:** gemini_ex test suite
105 | **Do Not Modify:** These are reference test images
106 | **Size:** Intentionally minimal for CI/CD efficiency
107 |
--------------------------------------------------------------------------------
/lib/gemini/types/response/inlined_embed_content_responses.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.InlinedEmbedContentResponses do
2 | @moduledoc """
3 | Container for all responses in an inline batch.
4 |
5 | Contains a list of InlinedEmbedContentResponse structs, each representing
6 | the result of one request from the batch.
7 |
8 | ## Fields
9 |
10 | - `inlined_responses`: List of InlinedEmbedContentResponse structs
11 |
12 | ## Examples
13 |
14 | %InlinedEmbedContentResponses{
15 | inlined_responses: [
16 | %InlinedEmbedContentResponse{response: ..., error: nil},
17 | %InlinedEmbedContentResponse{response: nil, error: ...}
18 | ]
19 | }
20 | """
21 |
22 | alias Gemini.Types.Response.{InlinedEmbedContentResponse, EmbedContentResponse}
23 |
24 | @enforce_keys [:inlined_responses]
25 | defstruct [:inlined_responses]
26 |
27 | @type t :: %__MODULE__{
28 | inlined_responses: [InlinedEmbedContentResponse.t()]
29 | }
30 |
31 | @doc """
32 | Creates an inlined responses container from API response data.
33 |
34 | ## Parameters
35 |
36 | - `data`: Map containing the API response with inlined responses
37 |
38 | ## Examples
39 |
40 | InlinedEmbedContentResponses.from_api_response(%{
41 | "inlinedResponses" => [
42 | %{"response" => %{"embedding" => ...}},
43 | %{"error" => %{"code" => 400}}
44 | ]
45 | })
46 | """
47 | @spec from_api_response(map()) :: t()
48 | def from_api_response(%{"inlinedResponses" => responses}) when is_list(responses) do
49 | inlined_responses = Enum.map(responses, &InlinedEmbedContentResponse.from_api_response/1)
50 |
51 | %__MODULE__{inlined_responses: inlined_responses}
52 | end
53 |
54 | @doc """
55 | Extracts all successful responses from the container.
56 |
57 | ## Returns
58 |
59 | List of EmbedContentResponse structs
60 |
61 | ## Examples
62 |
63 | successful = InlinedEmbedContentResponses.successful_responses(responses)
64 | # => [%EmbedContentResponse{...}, %EmbedContentResponse{...}]
65 | """
66 | @spec successful_responses(t()) :: [EmbedContentResponse.t()]
67 | def successful_responses(%__MODULE__{inlined_responses: responses}) do
68 | responses
69 | |> Enum.filter(&InlinedEmbedContentResponse.is_success?/1)
70 | |> Enum.map(& &1.response)
71 | end
72 |
73 | @doc """
74 | Extracts all failed responses with their indices and error details.
75 |
76 | ## Returns
77 |
78 | List of tuples: `{index, error_map}`
79 |
80 | ## Examples
81 |
82 | failures = InlinedEmbedContentResponses.failed_responses(responses)
83 | # => [{2, %{"code" => 400, "message" => "Invalid"}}, ...]
84 | """
85 | @spec failed_responses(t()) :: [{integer(), map()}]
86 | def failed_responses(%__MODULE__{inlined_responses: responses}) do
87 | responses
88 | |> Enum.with_index()
89 | |> Enum.filter(fn {response, _idx} -> InlinedEmbedContentResponse.is_error?(response) end)
90 | |> Enum.map(fn {response, idx} -> {idx, response.error} end)
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/test/gemini/types/response/batch_state_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Response.BatchStateTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.Types.Response.BatchState
5 |
6 | describe "from_string/1" do
7 | test "converts STATE_UNSPECIFIED to :unspecified" do
8 | assert BatchState.from_string("STATE_UNSPECIFIED") == :unspecified
9 | end
10 |
11 | test "converts PENDING to :pending" do
12 | assert BatchState.from_string("PENDING") == :pending
13 | end
14 |
15 | test "converts PROCESSING to :processing" do
16 | assert BatchState.from_string("PROCESSING") == :processing
17 | end
18 |
19 | test "converts COMPLETED to :completed" do
20 | assert BatchState.from_string("COMPLETED") == :completed
21 | end
22 |
23 | test "converts FAILED to :failed" do
24 | assert BatchState.from_string("FAILED") == :failed
25 | end
26 |
27 | test "converts CANCELLED to :cancelled" do
28 | assert BatchState.from_string("CANCELLED") == :cancelled
29 | end
30 |
31 | test "handles lowercase strings" do
32 | assert BatchState.from_string("pending") == :pending
33 | assert BatchState.from_string("processing") == :processing
34 | assert BatchState.from_string("completed") == :completed
35 | end
36 |
37 | test "defaults unknown states to :unspecified" do
38 | assert BatchState.from_string("UNKNOWN") == :unspecified
39 | assert BatchState.from_string("invalid") == :unspecified
40 | assert BatchState.from_string("") == :unspecified
41 | end
42 | end
43 |
44 | describe "to_string/1" do
45 | test "converts :unspecified to STATE_UNSPECIFIED" do
46 | assert BatchState.to_string(:unspecified) == "STATE_UNSPECIFIED"
47 | end
48 |
49 | test "converts :pending to PENDING" do
50 | assert BatchState.to_string(:pending) == "PENDING"
51 | end
52 |
53 | test "converts :processing to PROCESSING" do
54 | assert BatchState.to_string(:processing) == "PROCESSING"
55 | end
56 |
57 | test "converts :completed to COMPLETED" do
58 | assert BatchState.to_string(:completed) == "COMPLETED"
59 | end
60 |
61 | test "converts :failed to FAILED" do
62 | assert BatchState.to_string(:failed) == "FAILED"
63 | end
64 |
65 | test "converts :cancelled to CANCELLED" do
66 | assert BatchState.to_string(:cancelled) == "CANCELLED"
67 | end
68 | end
69 |
70 | describe "roundtrip conversion" do
71 | test "string -> atom -> string preserves value" do
72 | states = ["PENDING", "PROCESSING", "COMPLETED", "FAILED", "CANCELLED"]
73 |
74 | for state <- states do
75 | atom_state = BatchState.from_string(state)
76 | string_state = BatchState.to_string(atom_state)
77 | assert string_state == state
78 | end
79 | end
80 | end
81 |
82 | describe "type validation" do
83 | test "all valid states are atoms" do
84 | valid_states = [:unspecified, :pending, :processing, :completed, :failed, :cancelled]
85 |
86 | for state <- valid_states do
87 | assert is_atom(state)
88 | end
89 | end
90 | end
91 | end
92 |
--------------------------------------------------------------------------------
/test/gemini/apis/coordinator_model_path_building_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.APIs.CoordinatorModelPathBuildingTest do
2 | use ExUnit.Case, async: false
3 |
4 | alias Gemini.APIs.Coordinator
5 | alias Plug.Conn
6 |
7 | setup do
8 | bypass = Bypass.open()
9 |
10 | original_auth = Application.get_env(:gemini_ex, :auth)
11 |
12 | original_env =
13 | Map.new(
14 | ["GEMINI_API_KEY", "VERTEX_PROJECT_ID", "VERTEX_LOCATION", "VERTEX_ACCESS_TOKEN"],
15 | fn key -> {key, System.get_env(key)} end
16 | )
17 |
18 | :meck.new(Gemini.Auth, [:passthrough])
19 |
20 | on_exit(fn ->
21 | :meck.unload()
22 |
23 | if is_nil(original_auth) do
24 | Application.delete_env(:gemini_ex, :auth)
25 | else
26 | Application.put_env(:gemini_ex, :auth, original_auth)
27 | end
28 |
29 | Enum.each(original_env, fn
30 | {key, nil} -> System.delete_env(key)
31 | {key, value} -> System.put_env(key, value)
32 | end)
33 | end)
34 |
35 | %{bypass: bypass}
36 | end
37 |
38 | test "Gemini API preserves user model with endpoint suffix", %{bypass: bypass} do
39 | :meck.expect(Gemini.Auth, :get_base_url, fn _type, _creds ->
40 | "http://localhost:#{bypass.port}"
41 | end)
42 |
43 | System.put_env("GEMINI_API_KEY", "test-key")
44 | System.delete_env("VERTEX_PROJECT_ID")
45 | System.delete_env("VERTEX_LOCATION")
46 | System.delete_env("VERTEX_ACCESS_TOKEN")
47 |
48 | Application.put_env(:gemini_ex, :auth, %{type: :gemini, credentials: %{api_key: "test"}})
49 |
50 | Bypass.expect_once(
51 | bypass,
52 | "POST",
53 | "/models/gemini-3-pro-image-preview:generateContent",
54 | fn conn ->
55 | Conn.resp(conn, 200, ~s({"candidates":[]}))
56 | end
57 | )
58 |
59 | {:ok, _} =
60 | Coordinator.generate_content(
61 | "describe a banana",
62 | model: "gemini-3-pro-image-preview:generateContent",
63 | disable_rate_limiter: true
64 | )
65 | end
66 |
67 | test "Vertex AI preserves user model with endpoint suffix", %{bypass: bypass} do
68 | :meck.expect(Gemini.Auth, :get_base_url, fn _type, _creds ->
69 | "http://localhost:#{bypass.port}"
70 | end)
71 |
72 | System.delete_env("GEMINI_API_KEY")
73 | System.put_env("VERTEX_PROJECT_ID", "proj")
74 | System.put_env("VERTEX_LOCATION", "loc")
75 | System.put_env("VERTEX_ACCESS_TOKEN", "token")
76 |
77 | Application.put_env(:gemini_ex, :auth, %{
78 | type: :vertex_ai,
79 | credentials: %{project_id: "proj", location: "loc", access_token: "token"}
80 | })
81 |
82 | Bypass.expect_once(
83 | bypass,
84 | "POST",
85 | "/projects/proj/locations/loc/publishers/google/models/gemini-3-pro-image-preview:generateContent",
86 | fn conn ->
87 | Conn.resp(conn, 200, ~s({"candidates":[]}))
88 | end
89 | )
90 |
91 | {:ok, _} =
92 | Coordinator.generate_content(
93 | "describe a banana",
94 | model: "gemini-3-pro-image-preview:generateContent",
95 | disable_rate_limiter: true
96 | )
97 | end
98 | end
99 |
--------------------------------------------------------------------------------
/lib/gemini/tools.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Tools do
2 | @moduledoc """
3 | High-level facade for tool registration and execution in the Gemini client.
4 |
5 | This module provides a convenient interface for developers to register tool
6 | implementations and execute function calls returned by the Gemini API. It
7 | integrates with the ALTAR LATER runtime for robust tool execution.
8 |
9 | ## Usage
10 |
11 | # Register a tool
12 | {:ok, declaration} = Altar.ADM.new_function_declaration(%{
13 | name: "get_weather",
14 | description: "Gets weather for a location",
15 | parameters: %{}
16 | })
17 |
18 | :ok = Gemini.Tools.register(declaration, &MyApp.Tools.get_weather/1)
19 |
20 | # Execute function calls from API response
21 | function_calls = [%Altar.ADM.FunctionCall{...}]
22 | {:ok, results} = Gemini.Tools.execute_calls(function_calls)
23 | """
24 |
25 | alias Altar.ADM.{FunctionCall, FunctionDeclaration, ToolResult}
26 | alias Altar.LATER.{Registry, Executor}
27 |
28 | @registry_name Gemini.Tools.Registry
29 |
30 | @doc """
31 | Register a tool implementation with the LATER registry.
32 |
33 | - `declaration` is a validated `%Altar.ADM.FunctionDeclaration{}`
34 | - `fun` is an arity-1 function that accepts a map of arguments
35 |
36 | Returns `:ok` on success or `{:error, reason}` if registration fails.
37 | """
38 | @spec register(FunctionDeclaration.t(), (map() -> any())) :: :ok | {:error, term()}
39 | def register(%FunctionDeclaration{} = declaration, fun) when is_function(fun, 1) do
40 | Registry.register_tool(@registry_name, declaration, fun)
41 | end
42 |
43 | @doc """
44 | Execute a list of function calls in parallel using the LATER executor.
45 |
46 | Takes a list of `%Altar.ADM.FunctionCall{}` structs (typically from a
47 | GenerateContentResponse) and executes them concurrently, returning a list
48 | of `%Altar.ADM.ToolResult{}` structs.
49 |
50 | Returns `{:ok, [ToolResult.t()]}` on success. Individual tool failures
51 | are captured in the ToolResult's `is_error` field rather than causing
52 | the entire operation to fail.
53 | """
54 | @spec execute_calls([FunctionCall.t()]) :: {:ok, [ToolResult.t()]}
55 | def execute_calls(function_calls) when is_list(function_calls) do
56 | results =
57 | function_calls
58 | |> Task.async_stream(
59 | fn call ->
60 | {:ok, result} = Executor.execute_tool(@registry_name, call)
61 | result
62 | end,
63 | max_concurrency: System.schedulers_online(),
64 | timeout: 30_000
65 | )
66 | |> Enum.map(fn
67 | {:ok, result} -> result
68 | {:exit, reason} -> build_error_result("task_exit", reason)
69 | end)
70 |
71 | {:ok, results}
72 | end
73 |
74 | # Helper to build error results for task failures
75 | defp build_error_result(call_id, reason) do
76 | {:ok, result} =
77 | Altar.ADM.new_tool_result(%{
78 | call_id: call_id,
79 | is_error: true,
80 | content: %{error: "Task execution failed: #{inspect(reason)}"}
81 | })
82 |
83 | result
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/.kiro/specs/generation-config-bug-fix/requirements.md:
--------------------------------------------------------------------------------
1 | # Requirements Document
2 |
3 | ## Introduction
4 |
5 | This feature addresses a critical bug in the Gemini Elixir client where `generation_config` options (specifically `response_schema`, `response_mime_type`, and other advanced configuration options) are being dropped when using the main `Gemini` module functions and `Gemini.APIs.Coordinator`. The bug occurs because there are two different code paths for building generation requests: the working `Gemini.Generate` module and the broken `Gemini.APIs.Coordinator` module that only whitelists a few basic configuration keys.
6 |
7 | ## Requirements
8 |
9 | ### Requirement 1
10 |
11 | **User Story:** As a developer using the Gemini client, I want all generation configuration options to work consistently across all API entry points, so that I can use advanced features like structured output with response schemas regardless of which module I call.
12 |
13 | #### Acceptance Criteria
14 |
15 | 1. WHEN I call `Gemini.generate/2` with `response_schema` option THEN the system SHALL include the response schema in the API request
16 | 2. WHEN I call `Gemini.chat/1` with a `GenerationConfig` struct containing `response_schema` THEN the system SHALL preserve and use the response schema
17 | 3. WHEN I call `Gemini.APIs.Coordinator.generate_content/2` with any valid generation config option THEN the system SHALL include all provided options in the API request
18 | 4. WHEN I use individual keyword arguments for generation config THEN the system SHALL convert them to proper camelCase API format (e.g., `response_mime_type` becomes `responseMimeType`)
19 |
20 | ### Requirement 2
21 |
22 | **User Story:** As a developer, I want the same generation configuration behavior whether I use `Gemini.Generate` or `Gemini.APIs.Coordinator`, so that I can switch between modules without losing functionality.
23 |
24 | #### Acceptance Criteria
25 |
26 | 1. WHEN I call `Gemini.Generate.content/2` with generation config options THEN the system SHALL work correctly (existing behavior)
27 | 2. WHEN I call `Gemini.APIs.Coordinator.generate_content/2` with the same generation config options THEN the system SHALL produce identical API requests
28 | 3. WHEN I provide a complete `GenerationConfig` struct THEN both modules SHALL handle it identically
29 | 4. WHEN I provide individual keyword arguments THEN both modules SHALL convert them to the same API format
30 |
31 | ### Requirement 3
32 |
33 | **User Story:** As a developer, I want comprehensive test coverage that prevents regression of this bug, so that future changes don't break generation configuration handling.
34 |
35 | #### Acceptance Criteria
36 |
37 | 1. WHEN tests are run THEN there SHALL be a test that demonstrates the bug by failing with the current implementation
38 | 2. WHEN the fix is implemented THEN the same test SHALL pass
39 | 3. WHEN tests are run THEN there SHALL be tests covering both individual keyword arguments and complete GenerationConfig structs
40 | 4. WHEN tests are run THEN there SHALL be tests covering all major generation config options including `response_schema`, `response_mime_type`, `temperature`, `max_output_tokens`, `top_p`, and `top_k`
--------------------------------------------------------------------------------
/test/gemini/apis/coordinator_model_with_endpoint_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.APIs.CoordinatorModelWithEndpointTest do
2 | @moduledoc """
3 | Reproduces the reported issue where passing a model string that already includes
4 | `:generateContent` (e.g. `gemini-3-pro-image-preview:generateContent`) silently
5 | falls back to the default model, so no image-preview request is sent.
6 | """
7 |
8 | use ExUnit.Case, async: false
9 |
10 | alias Gemini.APIs.Coordinator
11 | alias Gemini.Config
12 |
13 | setup do
14 | original_key = System.get_env("GEMINI_API_KEY")
15 | original_auth = Application.get_env(:gemini_ex, :auth)
16 |
17 | System.put_env("GEMINI_API_KEY", "dummy-key")
18 |
19 | Application.put_env(:gemini_ex, :auth, %{
20 | type: :gemini,
21 | credentials: %{api_key: "test-api-key"}
22 | })
23 |
24 | :meck.new(Req, [:non_strict, :passthrough])
25 |
26 | test_pid = self()
27 |
28 | :meck.expect(Req, :request, fn req_opts ->
29 | send(test_pid, {:req_url, req_opts[:url]})
30 | {:ok, %Req.Response{status: 200, body: %{}}}
31 | end)
32 |
33 | on_exit(fn ->
34 | if is_nil(original_key) do
35 | System.delete_env("GEMINI_API_KEY")
36 | else
37 | System.put_env("GEMINI_API_KEY", original_key)
38 | end
39 |
40 | if is_nil(original_auth) do
41 | Application.delete_env(:gemini_ex, :auth)
42 | else
43 | Application.put_env(:gemini_ex, :auth, original_auth)
44 | end
45 |
46 | :meck.unload()
47 | end)
48 |
49 | :ok
50 | end
51 |
52 | test "model values that already contain :generateContent do not drop the explicit model" do
53 | user_model = "gemini-3-pro-image-preview:generateContent"
54 | default_model = Config.default_model()
55 |
56 | # Matches the user-provided example: the model already includes the endpoint suffix.
57 | {:ok, _response} =
58 | Coordinator.generate_content(
59 | [
60 | %{type: "text", text: "describe the banana"},
61 | %{type: "image", source: %{type: "base64", data: Base.encode64("fake-image")}}
62 | ],
63 | model: user_model,
64 | disable_rate_limiter: true
65 | )
66 |
67 | assert_receive {:req_url, url}
68 |
69 | # Expected: the generated URL should include the caller-provided model.
70 | assert String.contains?(url, "gemini-3-pro-image-preview"),
71 | """
72 | Request URL should preserve the explicit model when it already includes :generateContent
73 | (otherwise we silently hit the default model and nothing logs for the image-preview call).
74 | URL: #{url}
75 | """
76 |
77 | refute String.contains?(url, default_model),
78 | "Request unexpectedly fell back to the default model #{default_model}"
79 |
80 | refute String.contains?(url, ":generateContent:generateContent"),
81 | "Request should not double-append the endpoint"
82 | end
83 |
84 | test "invalid model strings raise instead of silently falling back" do
85 | assert_raise ArgumentError, fn ->
86 | Coordinator.generate_content("hello", model: "gemini-3-pro-image-preview?foo=1")
87 | end
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/lib/gemini/types/common/speech_config.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.PrebuiltVoiceConfig do
2 | @moduledoc """
3 | Configuration for a prebuilt voice.
4 | """
5 |
6 | use TypedStruct
7 |
8 | @derive Jason.Encoder
9 | typedstruct do
10 | field(:voice_name, String.t() | nil, default: nil)
11 | end
12 |
13 | @spec from_api(map() | nil) :: t() | nil
14 | def from_api(nil), do: nil
15 |
16 | def from_api(%{} = data) do
17 | %__MODULE__{
18 | voice_name: Map.get(data, "voiceName") || Map.get(data, :voice_name)
19 | }
20 | end
21 |
22 | @spec to_api(t() | nil) :: map() | nil
23 | def to_api(nil), do: nil
24 |
25 | def to_api(%__MODULE__{} = config) do
26 | %{"voiceName" => config.voice_name}
27 | |> Enum.reject(fn {_k, v} -> is_nil(v) end)
28 | |> Enum.into(%{})
29 | end
30 | end
31 |
32 | defmodule Gemini.Types.VoiceConfig do
33 | @moduledoc """
34 | Voice configuration for speech synthesis.
35 | """
36 |
37 | use TypedStruct
38 |
39 | alias Gemini.Types.PrebuiltVoiceConfig
40 |
41 | @derive Jason.Encoder
42 | typedstruct do
43 | field(:prebuilt_voice_config, PrebuiltVoiceConfig.t() | nil, default: nil)
44 | end
45 |
46 | @spec from_api(map() | nil) :: t() | nil
47 | def from_api(nil), do: nil
48 |
49 | def from_api(%{} = data) do
50 | %__MODULE__{
51 | prebuilt_voice_config:
52 | data
53 | |> Map.get("prebuiltVoiceConfig")
54 | |> Kernel.||(Map.get(data, :prebuilt_voice_config))
55 | |> PrebuiltVoiceConfig.from_api()
56 | }
57 | end
58 |
59 | @spec to_api(t() | nil) :: map() | nil
60 | def to_api(nil), do: nil
61 |
62 | def to_api(%__MODULE__{} = config) do
63 | prebuilt = PrebuiltVoiceConfig.to_api(config.prebuilt_voice_config)
64 |
65 | %{"prebuiltVoiceConfig" => prebuilt}
66 | |> Enum.reject(fn {_k, v} -> is_nil(v) end)
67 | |> Enum.into(%{})
68 | end
69 | end
70 |
71 | defmodule Gemini.Types.SpeechConfig do
72 | @moduledoc """
73 | Speech generation configuration.
74 | """
75 |
76 | use TypedStruct
77 |
78 | alias Gemini.Types.VoiceConfig
79 |
80 | @derive Jason.Encoder
81 | typedstruct do
82 | field(:language_code, String.t() | nil, default: nil)
83 | field(:voice_config, VoiceConfig.t() | nil, default: nil)
84 | end
85 |
86 | @spec from_api(map() | nil) :: t() | nil
87 | def from_api(nil), do: nil
88 |
89 | def from_api(%{} = data) do
90 | %__MODULE__{
91 | language_code: Map.get(data, "languageCode") || Map.get(data, :language_code),
92 | voice_config:
93 | data
94 | |> Map.get("voiceConfig")
95 | |> Kernel.||(Map.get(data, :voice_config))
96 | |> VoiceConfig.from_api()
97 | }
98 | end
99 |
100 | @spec to_api(t() | nil) :: map() | nil
101 | def to_api(nil), do: nil
102 |
103 | def to_api(%__MODULE__{} = config) do
104 | voice = VoiceConfig.to_api(config.voice_config)
105 |
106 | %{
107 | "languageCode" => config.language_code,
108 | "voiceConfig" => voice
109 | }
110 | |> Enum.reject(fn {_k, v} -> is_nil(v) end)
111 | |> Enum.into(%{})
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/docs/technical/initiatives/README.md:
--------------------------------------------------------------------------------
1 | # Technical Initiatives Index
2 |
3 | This directory contains comprehensive technical design documents for major features and fixes in the Gemini Elixir client.
4 |
5 | ## Active Initiatives
6 |
7 | ### Initiative 001: Multimodal Content Input Flexibility
8 | **Status:** 🔴 CRITICAL - In Design
9 | **Priority:** P0 - Blocking User Functionality
10 | **Related Issue:** [#11](https://github.com/nshkrdotcom/gemini_ex/issues/11)
11 |
12 | Enable flexible input formats for multimodal content (text + images/video/audio), allowing users to pass intuitive plain maps instead of requiring rigid struct types.
13 |
14 | **Document:** [001_multimodal_input_flexibility.md](./001_multimodal_input_flexibility.md)
15 |
16 | **Key Features:**
17 | - Accept Anthropic-style content maps
18 | - Accept Gemini API-style maps
19 | - Auto-detect image MIME types from base64 data
20 | - Maintain backward compatibility
21 | - Comprehensive error messages
22 |
23 | **Estimated Effort:** 4-6 hours
24 | **Impact:** Unblocks all multimodal use cases for users
25 |
26 | ---
27 |
28 | ## Planned Initiatives
29 |
30 | ### Initiative 002: Thinking Budget Configuration Fix
31 | **Status:** 🟡 Planned
32 | **Priority:** P0 - Critical Bug Fix
33 | **Related PR:** [#10](https://github.com/nshkrdotcom/gemini_ex/pull/10)
34 |
35 | Fix critical bug in thinking budget configuration where field names are sent incorrectly to the API, preventing the feature from working.
36 |
37 | **Estimated Effort:** 4-6 hours
38 | **Impact:** Fixes broken cost optimization feature
39 |
40 | ---
41 |
42 | ## Initiative Template
43 |
44 | Each initiative document follows this structure:
45 |
46 | 1. **Executive Summary** - Problem, solution, success criteria, impact
47 | 2. **Problem Analysis** - Current behavior, root cause, user impact
48 | 3. **Official API Specification** - What the API actually expects
49 | 4. **Current Implementation Analysis** - How our code works now
50 | 5. **Proposed Solution** - High-level approach and detailed plan
51 | 6. **Implementation Details** - Code changes, function signatures, etc.
52 | 7. **Backward Compatibility** - Migration path, deprecations
53 | 8. **Testing Strategy** - Unit, integration, live API tests
54 | 9. **Documentation Updates** - README, guides, examples
55 | 10. **Implementation Checklist** - Step-by-step tasks with estimates
56 | 11. **Risk Analysis** - Potential issues and mitigation
57 | 12. **References** - Links, code, related issues
58 |
59 | ## Creating a New Initiative
60 |
61 | 1. Copy the template structure from Initiative 001
62 | 2. Create `docs/technical/initiatives/XXX_initiative_name.md`
63 | 3. Fill in all sections with detailed analysis
64 | 4. Add to this index
65 | 5. Link from related GitHub issues
66 |
67 | ## Cross-References
68 |
69 | - **Issue Analysis:** `docs/issues/ISSUE_ANALYSIS.md`
70 | - **Initiative Comparison:** `docs/technical/INITIATIVE_ANALYSIS.md`
71 | - **Official API Docs:** `docs/gemini_api_reference_2025_10_07/`
72 | - **Code Quality Standards:** `CODE_QUALITY.md`
73 | - **Project Context:** `CLAUDE.md`
74 |
75 | ---
76 |
77 | **Directory Created:** 2025-10-07
78 | **Maintained By:** Core Maintainers
79 | **Purpose:** Provide comprehensive technical specifications for major changes
80 |
--------------------------------------------------------------------------------
/oldDocs/docs/spec/GEMINI-DOCS-02-API-KEYS.md:
--------------------------------------------------------------------------------
1 | # Get a Gemini API key
2 |
3 | To use the Gemini API, you need an API key. You can create a key with a few clicks in Google AI Studio.
4 |
5 | [Get a Gemini API key in Google AI Studio]
6 |
7 | ## Set up your API key
8 |
9 | For initial testing, you can hard code an API key, but this should only be temporary since it is not secure. The rest of this section goes through how to set up your API key locally as an environment variable with different operating systems.
10 |
11 | ### Linux/macOS - Bash
12 |
13 | Bash is a common Linux and macOS terminal configuration. You can check if you have a configuration file for it by running the following command:
14 |
15 | ```bash
16 | ~/.bashrc
17 | ```
18 |
19 | If the response is "No such file or directory", you will need to create this file and open it by running the following commands, or use `zsh`:
20 |
21 | ```bash
22 | touch ~/.bashrc
23 | open ~/.bashrc
24 | ```
25 |
26 | Next, you need to set your API key by adding the following export command:
27 |
28 | ```bash
29 | export GEMINI_API_KEY=
30 | ```
31 |
32 | After saving the file, apply the changes by running:
33 |
34 | ```bash
35 | source ~/.bashrc
36 | ```
37 |
38 | ### macOS - Zsh
39 |
40 | (Instructions for Zsh are typically similar to Bash, often using `~/.zshrc`. The provided text implies using `zsh` as an alternative but doesn't give explicit `zsh` steps distinct from Bash in this snippet, so the Bash steps are generally applicable for environment variables).
41 |
42 | ### Windows
43 |
44 | (The provided text mentions Windows but does not include the specific commands for setting environment variables on Windows. Common methods involve the Command Prompt `set` command or PowerShell `$env:` prefix, often added to user or system environment variables through the system properties GUI for persistence.)
45 |
46 | ## Send your first Gemini API request
47 |
48 | You can use a curl command to verify your setup:
49 |
50 | ```bash
51 | curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${GEMINI_API_KEY}" \
52 | -H 'Content-Type: application/json' \
53 | -X POST \
54 | -d '{
55 | "contents": [
56 | {
57 | "parts": [
58 | {
59 | "text": "Write a story about a magic backpack."
60 | }
61 | ]
62 | }
63 | ]
64 | }'
65 | ```
66 |
67 | ## Keep your API key secure
68 |
69 | It's important to keep your Gemini API key secure. Here are a few things to keep in mind when using your Gemini API key:
70 |
71 | * The Google AI Gemini API uses API keys for authorization. If others get access to your Gemini API key, they can make calls using your project's quota, which could result in lost quota or additional charges for billed projects, in addition to accessing tuned models and files.
72 | * Adding [API key restrictions] can help limit the surface area usable through each API key.
73 | * You're responsible for keeping your Gemini API key secure.
74 | * Do NOT check Gemini API keys into source control.
75 | * Client-side applications (Android, Swift, web, and Dart/Flutter) risk exposing API keys. We don't recommend using the Google AI client SDKs in production apps to call the Google AI Gemini API directly from your mobile and web apps.
76 | * For some general best practices, you can also review this [support article].
77 |
78 |
--------------------------------------------------------------------------------
/lib/gemini/error.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Error do
2 | @moduledoc """
3 | Standardized error structure for Gemini client.
4 | """
5 |
6 | use TypedStruct
7 |
8 | typedstruct do
9 | field(:type, atom(), enforce: true)
10 | field(:message, String.t(), enforce: true)
11 | field(:http_status, integer() | nil, default: nil)
12 | field(:api_reason, term() | nil, default: nil)
13 | field(:details, map() | nil, default: nil)
14 | field(:original_error, term() | nil, default: nil)
15 | end
16 |
17 | @typedoc "The type of error."
18 | @type error_type :: atom()
19 |
20 | @typedoc "A human-readable message describing the error."
21 | @type error_message :: String.t()
22 |
23 | @typedoc "The HTTP status code, if the error originated from an HTTP response."
24 | @type http_status :: integer() | nil
25 |
26 | @typedoc "API-specific error code or reason, if provided by Gemini."
27 | @type api_reason :: term() | nil
28 |
29 | @typedoc "Additional details or context about the error."
30 | @type error_details :: map() | nil
31 |
32 | @typedoc "The original error term, if this error is wrapping another."
33 | @type original_error :: term() | nil
34 |
35 | @doc """
36 | Create a new error with type and message.
37 | """
38 | def new(type, message, attrs \\ []) do
39 | struct!(__MODULE__, [{:type, type}, {:message, message} | attrs])
40 | end
41 |
42 | @doc """
43 | Create an HTTP error.
44 | """
45 | def http_error(status, message, details \\ %{}) do
46 | new(:http_error, message, http_status: status, details: details)
47 | end
48 |
49 | @doc """
50 | Create an API error from Gemini response.
51 | """
52 | def api_error(reason, message, details \\ %{}) do
53 | http_status =
54 | case reason do
55 | status when is_integer(status) -> status
56 | _ -> nil
57 | end
58 |
59 | new(:api_error, message,
60 | api_reason: reason,
61 | http_status: http_status,
62 | details: details
63 | )
64 | end
65 |
66 | @doc """
67 | Create a configuration error.
68 | """
69 | def config_error(message, details \\ %{}) do
70 | new(:config_error, message, details: details)
71 | end
72 |
73 | @doc """
74 | Create an authentication error.
75 |
76 | This error type is used when authentication fails, such as:
77 | - Invalid API keys
78 | - Service account token generation failures
79 | - Missing or invalid credentials
80 | """
81 | def auth_error(message, details \\ %{}) do
82 | new(:auth_error, message, details: details)
83 | end
84 |
85 | @doc """
86 | Create a request validation error.
87 | """
88 | def validation_error(message, details \\ %{}) do
89 | new(:validation_error, message, details: details)
90 | end
91 |
92 | @doc """
93 | Create a JSON serialization/deserialization error.
94 | """
95 | def serialization_error(message, details \\ %{}) do
96 | new(:serialization_error, message, details: details)
97 | end
98 |
99 | @doc """
100 | Create a network/connection error.
101 | """
102 | def network_error(message, original_error \\ nil) do
103 | new(:network_error, message, original_error: original_error)
104 | end
105 |
106 | @doc """
107 | Create an invalid response error.
108 | """
109 | def invalid_response(message, details \\ %{}) do
110 | new(:invalid_response, message, details: details)
111 | end
112 | end
113 |
--------------------------------------------------------------------------------
/test/gemini/telemetry_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.TelemetryTest do
2 | use ExUnit.Case
3 |
4 | @moduletag :capture_log
5 |
6 | import Gemini.Test.ModelHelpers
7 |
8 | setup do
9 | # Install telemetry test handler
10 | :telemetry_test.attach_event_handlers(self(), [
11 | [:gemini, :request, :start],
12 | [:gemini, :request, :stop],
13 | [:gemini, :request, :exception],
14 | [:gemini, :stream, :start],
15 | [:gemini, :stream, :chunk],
16 | [:gemini, :stream, :stop],
17 | [:gemini, :stream, :exception]
18 | ])
19 |
20 | on_exit(fn ->
21 | :telemetry.detach("telemetry-test")
22 | end)
23 |
24 | :ok
25 | end
26 |
27 | describe "telemetry events" do
28 | test "emits request start and stop events" do
29 | # Configure telemetry
30 | Application.put_env(:gemini_ex, :telemetry_enabled, true)
31 |
32 | # This would normally make a real request, but we'll just test the telemetry
33 | # infrastructure is in place
34 | assert Gemini.Config.telemetry_enabled?() == true
35 | end
36 |
37 | test "classify_contents/1 correctly identifies content types" do
38 | assert Gemini.Telemetry.classify_contents("Hello world") == :text
39 |
40 | assert Gemini.Telemetry.classify_contents([
41 | %{parts: [%{text: "Hello"}]}
42 | ]) == :text
43 |
44 | assert Gemini.Telemetry.classify_contents([
45 | %{parts: [%{text: "Hello"}, %{image: "data"}]}
46 | ]) == :multimodal
47 | end
48 |
49 | test "generate_stream_id/0 creates unique IDs" do
50 | id1 = Gemini.Telemetry.generate_stream_id()
51 | id2 = Gemini.Telemetry.generate_stream_id()
52 |
53 | assert is_binary(id1)
54 | assert is_binary(id2)
55 | assert id1 != id2
56 | # 8 bytes * 2 chars per byte
57 | assert String.length(id1) == 16
58 | end
59 |
60 | test "build_request_metadata/3 creates proper metadata" do
61 | opts = [
62 | model: default_model(),
63 | function: :generate_content,
64 | contents_type: :text
65 | ]
66 |
67 | metadata = Gemini.Telemetry.build_request_metadata("https://example.com", :post, opts)
68 |
69 | assert metadata.url == "https://example.com"
70 | assert metadata.method == :post
71 | assert metadata.model == default_model()
72 | assert metadata.function == :generate_content
73 | assert metadata.contents_type == :text
74 | assert is_integer(metadata.system_time)
75 | end
76 |
77 | test "calculate_duration/1 returns positive duration" do
78 | start_time = System.monotonic_time()
79 | # Small delay
80 | Process.sleep(1)
81 | duration = Gemini.Telemetry.calculate_duration(start_time)
82 |
83 | assert is_integer(duration)
84 | assert duration >= 0
85 | end
86 | end
87 |
88 | describe "telemetry configuration" do
89 | test "telemetry can be disabled" do
90 | Application.put_env(:gemini_ex, :telemetry_enabled, false)
91 | assert Gemini.Config.telemetry_enabled?() == false
92 |
93 | Application.put_env(:gemini_ex, :telemetry_enabled, true)
94 | assert Gemini.Config.telemetry_enabled?() == true
95 |
96 | Application.delete_env(:gemini_ex, :telemetry_enabled)
97 | # default is true
98 | assert Gemini.Config.telemetry_enabled?() == true
99 | end
100 | end
101 | end
102 |
--------------------------------------------------------------------------------
/lib/gemini/types/interactions/agent_config.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Interactions.DynamicAgentConfig do
2 | @moduledoc """
3 | Dynamic agent configuration (`type: "dynamic"`).
4 |
5 | Python allows arbitrary extra keys; in Elixir we store them under `config`.
6 | """
7 |
8 | use TypedStruct
9 |
10 | @derive Jason.Encoder
11 | typedstruct do
12 | field(:type, String.t(), default: "dynamic")
13 | field(:config, map(), default: %{})
14 | end
15 |
16 | @spec from_api(map() | nil) :: t() | nil
17 | def from_api(nil), do: nil
18 | def from_api(%__MODULE__{} = config), do: config
19 |
20 | def from_api(%{} = data) do
21 | %__MODULE__{
22 | type: Map.get(data, "type") || "dynamic",
23 | config: Map.drop(data, ["type"])
24 | }
25 | end
26 |
27 | @spec to_api(t() | map() | nil) :: map() | nil
28 | def to_api(nil), do: nil
29 | def to_api(%{} = map) when not is_struct(map), do: map
30 |
31 | def to_api(%__MODULE__{} = config) do
32 | Map.merge(%{"type" => "dynamic"}, config.config || %{})
33 | end
34 | end
35 |
36 | defmodule Gemini.Types.Interactions.DeepResearchAgentConfig do
37 | @moduledoc """
38 | Deep Research agent configuration (`type: "deep-research"`).
39 | """
40 |
41 | use TypedStruct
42 |
43 | @type thinking_summaries :: String.t()
44 |
45 | @derive Jason.Encoder
46 | typedstruct do
47 | field(:type, String.t(), default: "deep-research")
48 | field(:thinking_summaries, thinking_summaries())
49 | end
50 |
51 | @spec from_api(map() | nil) :: t() | nil
52 | def from_api(nil), do: nil
53 | def from_api(%__MODULE__{} = config), do: config
54 |
55 | def from_api(%{} = data) do
56 | %__MODULE__{
57 | type: Map.get(data, "type") || "deep-research",
58 | thinking_summaries: Map.get(data, "thinking_summaries")
59 | }
60 | end
61 |
62 | @spec to_api(t() | map() | nil) :: map() | nil
63 | def to_api(nil), do: nil
64 | def to_api(%{} = map) when not is_struct(map), do: map
65 |
66 | def to_api(%__MODULE__{} = config) do
67 | %{"type" => "deep-research"}
68 | |> maybe_put("thinking_summaries", config.thinking_summaries)
69 | end
70 |
71 | defp maybe_put(map, _key, nil), do: map
72 | defp maybe_put(map, key, value), do: Map.put(map, key, value)
73 | end
74 |
75 | defmodule Gemini.Types.Interactions.AgentConfig do
76 | @moduledoc """
77 | Agent config union (`DynamicAgentConfig | DeepResearchAgentConfig`).
78 | """
79 |
80 | alias Gemini.Types.Interactions.{DeepResearchAgentConfig, DynamicAgentConfig}
81 |
82 | @type t :: DynamicAgentConfig.t() | DeepResearchAgentConfig.t() | map()
83 |
84 | @spec from_api(map() | t() | nil) :: t() | nil
85 | def from_api(nil), do: nil
86 | def from_api(%DynamicAgentConfig{} = cfg), do: cfg
87 | def from_api(%DeepResearchAgentConfig{} = cfg), do: cfg
88 |
89 | def from_api(%{} = data) do
90 | case Map.get(data, "type") do
91 | "deep-research" -> DeepResearchAgentConfig.from_api(data)
92 | "dynamic" -> DynamicAgentConfig.from_api(data)
93 | _ -> DynamicAgentConfig.from_api(data)
94 | end
95 | end
96 |
97 | @spec to_api(t() | nil) :: map() | nil
98 | def to_api(nil), do: nil
99 | def to_api(%{} = map) when not is_struct(map), do: map
100 | def to_api(%DynamicAgentConfig{} = cfg), do: DynamicAgentConfig.to_api(cfg)
101 | def to_api(%DeepResearchAgentConfig{} = cfg), do: DeepResearchAgentConfig.to_api(cfg)
102 | end
103 |
--------------------------------------------------------------------------------
/oldDocs/docs/spec/GEMINI-DOCS-22-GROUNDING-WITH-GOOGLE-SEARCH_USE-GOOGLE-SEARCH-SUGGESTIONS.md:
--------------------------------------------------------------------------------
1 | # Use Google Search Suggestions
2 |
3 | To use Grounding with Google Search, you must enable Google Search Suggestions, which help users find search results corresponding to a grounded response.
4 |
5 | Specifically, you need to display the search queries that are included in the grounded response's metadata. The response includes:
6 |
7 | * `content`: LLM generated response
8 | * `webSearchQueries`: The queries to be used for Google Search Suggestions
9 |
10 | For example, in the following code snippet, Gemini responds to a search-grounded prompt which is asking about a type of tropical plant.
11 |
12 | ```json
13 | "predictions": [
14 | {
15 | "content": "Monstera is a type of vine that thrives in bright indirect light…",
16 | "groundingMetadata": {
17 | "webSearchQueries": ["What's a monstera?"],
18 | }
19 | }
20 | ]
21 | ```
22 |
23 | You can take this output and display it by using Google Search Suggestions.
24 |
25 | ## Requirements for Google Search Suggestions
26 |
27 | **Do:**
28 |
29 | * Display the Search Suggestion exactly as provided without any modifications while complying with the [Display Requirements](https://www.google.com/search?q=%23display-requirements).
30 | * Take users directly to the Google Search results page (SRP) when they interact with the Search Suggestion.
31 |
32 | **Don't:**
33 |
34 | * Include any interstitial screens or additional steps between the user's tap and the display of the SRP.
35 | * Display any other search results or suggestions alongside the Search Suggestion or associated grounded LLM response.
36 |
37 | ## Display requirements
38 |
39 | Display the Search Suggestion exactly as provided and don't make any modifications to colors, fonts, or appearance. Ensure the Search Suggestion renders as specified in the following mocks, including for light and dark mode:
40 |
41 | (Image mocks are not convertible to simple markdown and are omitted)
42 |
43 | Whenever a grounded response is shown, its corresponding Google Search Suggestion should remain visible.
44 |
45 | * **Branding:** You must strictly follow [Google's Guidelines for Third Party Use of Google Brand Features].
46 | * Google Search Suggestions should be at minimum the full width of the grounded response.
47 |
48 | ## Behavior on tap
49 |
50 | When a user taps the chip, they are taken directly to a Google Search results page (SRP) for the search term displayed in the chip. The SRP can open either within your in-app browser or in a separate browser app. It's important to not minimize, remove, or obstruct the SRP's display in any way. The following animated mockup illustrates the tap-to-SRP interaction.
51 |
52 | (Animated mockup is not convertible to simple markdown and is omitted)
53 |
54 | ## Code to implement a Google Search Suggestion
55 |
56 | When you use the API to ground a response to search, the model response provides compliant HTML and CSS styling in the `renderedContent` field which you implement to display Search Suggestions in your application. To see an example of the API response, see the response section in [Grounding with Google Search].
57 |
58 | **Note:** The provided HTML and CSS provided in the API response automatically adapts to the user's device settings, displaying in either light or dark mode based on the user's preference indicated by `@media(prefers-color-scheme)`.
59 |
60 | ## What's next
61 |
62 | * Learn how to [build an interactive chat].
63 | * Learn how to [use Gemini safely and responsibly].
64 |
--------------------------------------------------------------------------------
/examples/tool_calling_demo.exs:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env elixir
2 |
3 | # Tool Calling Demo
4 | # This example demonstrates the deserialization and serialization of tool calling data
5 |
6 | alias Gemini.Generate
7 | alias Gemini.Types.Content
8 | alias Altar.ADM.ToolResult
9 |
10 | # Example 1: Parsing a mock API response with function calls
11 | IO.puts("=== Example 1: Parsing Function Calls ===")
12 |
13 | mock_api_response = %{
14 | "candidates" => [
15 | %{
16 | "content" => %{
17 | "role" => "model",
18 | "parts" => [
19 | %{
20 | "text" => "I'll help you get the weather information."
21 | },
22 | %{
23 | "functionCall" => %{
24 | "name" => "get_weather",
25 | "args" => %{"location" => "San Francisco", "units" => "celsius"},
26 | "call_id" => "call_weather_123"
27 | }
28 | }
29 | ]
30 | },
31 | "finishReason" => "STOP"
32 | }
33 | ]
34 | }
35 |
36 | case Generate.parse_generate_response(mock_api_response) do
37 | {:ok, response} ->
38 | IO.puts("✅ Successfully parsed response!")
39 |
40 | [candidate] = response.candidates
41 | [text_part, function_part] = candidate.content.parts
42 |
43 | IO.puts("Text part: #{text_part.text}")
44 | IO.puts("Function call: #{function_part.function_call.name}")
45 | IO.puts("Arguments: #{inspect(function_part.function_call.args)}")
46 | IO.puts("Call ID: #{function_part.function_call.call_id}")
47 |
48 | {:error, error} ->
49 | IO.puts("❌ Error parsing response: #{error.message}")
50 | end
51 |
52 | IO.puts("\n=== Example 2: Creating Tool Results ===")
53 |
54 | # Example 2: Creating tool results for function responses
55 | {:ok, result1} =
56 | ToolResult.new(%{
57 | call_id: "call_weather_123",
58 | content: %{
59 | "temperature" => 22,
60 | "condition" => "sunny",
61 | "humidity" => 65,
62 | "location" => "San Francisco"
63 | },
64 | is_error: false
65 | })
66 |
67 | {:ok, result2} =
68 | ToolResult.new(%{
69 | call_id: "call_time_456",
70 | content: "2024-01-15 14:30:00 PST",
71 | is_error: false
72 | })
73 |
74 | tool_results = [result1, result2]
75 |
76 | content = Content.from_tool_results(tool_results)
77 |
78 | IO.puts("✅ Created tool response content!")
79 | IO.puts("Role: #{content.role}")
80 | IO.puts("Number of parts: #{length(content.parts)}")
81 |
82 | # Show the JSON structure that would be sent to the API
83 | json_structure = Jason.encode!(content, pretty: true)
84 | IO.puts("JSON structure:")
85 | IO.puts(json_structure)
86 |
87 | IO.puts("\n=== Example 3: Error Handling ===")
88 |
89 | # Example 3: Handling malformed function calls
90 | malformed_response = %{
91 | "candidates" => [
92 | %{
93 | "content" => %{
94 | "role" => "model",
95 | "parts" => [
96 | %{
97 | "functionCall" => %{
98 | # Missing required "name" field
99 | "args" => %{"location" => "Paris"},
100 | "call_id" => "call_invalid"
101 | }
102 | }
103 | ]
104 | },
105 | "finishReason" => "STOP"
106 | }
107 | ]
108 | }
109 |
110 | case Generate.parse_generate_response(malformed_response) do
111 | {:ok, _response} ->
112 | IO.puts("❌ Should have failed!")
113 |
114 | {:error, error} ->
115 | IO.puts("✅ Correctly caught malformed function call!")
116 | IO.puts("Error: #{error.message}")
117 | end
118 |
119 | IO.puts("\n=== Demo Complete ===")
120 | IO.puts("The tool calling deserialization and serialization is working correctly!")
121 |
--------------------------------------------------------------------------------
/examples/streaming_demo.exs:
--------------------------------------------------------------------------------
1 | # Simple Live Streaming Demo
2 | # Usage: mix run examples/streaming_demo.exs
3 |
4 | defmodule StreamingDemo do
5 | defp mask_api_key(key) when is_binary(key) and byte_size(key) > 2 do
6 | first_two = String.slice(key, 0, 2)
7 | "#{first_two}***"
8 | end
9 | defp mask_api_key(_), do: "***"
10 |
11 | def run do
12 | IO.puts("🌊 Gemini Streaming Demo")
13 | IO.puts("========================")
14 |
15 | # Configure authentication
16 | case configure_auth() do
17 | :ok ->
18 | IO.puts("✅ Authentication configured successfully")
19 | start_streaming_demo()
20 | {:error, reason} ->
21 | IO.puts("❌ Authentication failed: #{reason}")
22 | System.halt(1)
23 | end
24 | end
25 |
26 | defp configure_auth do
27 | cond do
28 | vertex_key = System.get_env("VERTEX_JSON_FILE") ->
29 | IO.puts("🔑 Using Vertex AI authentication (file: #{vertex_key})")
30 | Gemini.configure(:vertex_ai, %{
31 | service_account_key: vertex_key,
32 | project_id: System.get_env("VERTEX_PROJECT_ID"),
33 | location: System.get_env("VERTEX_LOCATION") || "us-central1"
34 | })
35 | :ok
36 |
37 | api_key = System.get_env("GEMINI_API_KEY") ->
38 | IO.puts("🔑 Using Gemini API authentication (key: #{mask_api_key(api_key)})")
39 | Gemini.configure(:gemini, %{api_key: api_key})
40 | :ok
41 |
42 | true ->
43 | {:error, "No authentication credentials found. Set VERTEX_JSON_FILE or GEMINI_API_KEY"}
44 | end
45 | end
46 |
47 | defp start_streaming_demo do
48 | prompt = "Write a short creative story about a robot learning to paint. Make it about 3 paragraphs."
49 |
50 | IO.puts("\n📝 Prompt: #{prompt}")
51 | IO.puts("\n🚀 Starting real-time stream...\n")
52 |
53 | case Gemini.start_stream(prompt) do
54 | {:ok, stream_id} ->
55 | IO.puts("Stream ID: #{stream_id}")
56 |
57 | # Subscribe to the stream
58 | :ok = Gemini.subscribe_stream(stream_id)
59 |
60 | # Let's also check stream info
61 | case Gemini.get_stream_status(stream_id) do
62 | {:ok, info} -> IO.puts("Stream info: #{inspect(info)}")
63 | _ -> :ok
64 | end
65 |
66 | # Listen for streaming events
67 | listen_for_events()
68 |
69 | {:error, reason} ->
70 | IO.puts("❌ Failed to start stream: #{inspect(reason)}")
71 | end
72 | end
73 |
74 | defp listen_for_events do
75 | receive do
76 | {:stream_event, _stream_id, %{type: :data, data: data}} ->
77 | # Extract text content from the streaming response
78 | text_content = extract_text_from_stream_data(data)
79 | if text_content && text_content != "" do
80 | IO.write(text_content)
81 | end
82 | listen_for_events()
83 |
84 | {:stream_complete, _stream_id} ->
85 | IO.puts("\n\n✅ Stream completed!")
86 |
87 | {:stream_error, _stream_id, error} ->
88 | IO.puts("\n❌ Stream error: #{inspect(error)}")
89 |
90 | after
91 | 30_000 ->
92 | IO.puts("\n⏰ Stream timeout after 30 seconds")
93 | end
94 | end
95 |
96 | defp extract_text_from_stream_data(%{"candidates" => [%{"content" => %{"parts" => parts}} | _]}) do
97 | parts
98 | |> Enum.find(&Map.has_key?(&1, "text"))
99 | |> case do
100 | %{"text" => text} -> text
101 | _ -> nil
102 | end
103 | end
104 |
105 | defp extract_text_from_stream_data(_), do: nil
106 | end
107 |
108 | StreamingDemo.run()
--------------------------------------------------------------------------------
/test/live_api_test.exs:
--------------------------------------------------------------------------------
1 | defmodule LiveAPITest do
2 | use ExUnit.Case
3 |
4 | @moduletag :live_api
5 | @moduletag timeout: 30_000
6 |
7 | @moduledoc """
8 | Live API tests for Gemini library with both authentication methods and streaming.
9 | Run with: mix test test/live_api_test.exs --include live_api
10 | """
11 |
12 | require Logger
13 |
14 | import Gemini.Test.ModelHelpers
15 |
16 | defp mask_api_key(key) when is_binary(key) and byte_size(key) > 2 do
17 | first_two = String.slice(key, 0, 2)
18 | "#{first_two}***"
19 | end
20 |
21 | defp mask_api_key(_), do: "***"
22 |
23 | setup_all do
24 | Application.ensure_all_started(:gemini)
25 | {:ok, %{has_auth: auth_available?()}}
26 | end
27 |
28 | describe "Configuration Detection" do
29 | test "detects available authentication", %{has_auth: has_auth} do
30 | IO.puts("\n📋 Testing Configuration Detection")
31 | IO.puts("-" |> String.duplicate(40))
32 |
33 | auth_config = Gemini.Config.auth_config()
34 | auth_type = Gemini.Config.detect_auth_type()
35 | IO.puts("Detected auth type: #{auth_type}")
36 |
37 | default_model = Gemini.Config.default_model()
38 | IO.puts("Default model: #{default_model}")
39 |
40 | if has_auth do
41 | assert auth_config != nil
42 | else
43 | IO.puts("❌ No auth configured; skipping config assertion")
44 | assert true
45 | end
46 | end
47 | end
48 |
49 | describe "Gemini API Authentication" do
50 | test "gemini api text generation" do
51 | IO.puts("\n🔑 Testing Gemini API Authentication")
52 | IO.puts("-" |> String.duplicate(40))
53 |
54 | api_key = System.get_env("GEMINI_API_KEY")
55 |
56 | if api_key do
57 | Gemini.configure(:gemini, %{api_key: api_key})
58 | IO.puts("Configured Gemini API with key: #{mask_api_key(api_key)}")
59 |
60 | IO.puts("\n 📝 Testing simple text generation with Gemini API")
61 |
62 | case Gemini.generate("What is the capital of France? Give a brief answer.") do
63 | {:ok, response} ->
64 | case Gemini.extract_text(response) do
65 | {:ok, text} ->
66 | IO.puts(" ✅ Success: #{String.slice(text, 0, 100)}...")
67 | assert String.contains?(String.downcase(text), "paris")
68 |
69 | {:error, error} ->
70 | IO.puts(" ❌ Text extraction failed: #{error}")
71 | flunk("Text extraction failed: #{error}")
72 | end
73 |
74 | {:error, error} ->
75 | IO.puts(" ❌ Generation failed: #{inspect(error)}")
76 | flunk("Generation failed: #{inspect(error)}")
77 | end
78 | else
79 | IO.puts("❌ GEMINI_API_KEY not found, skipping Gemini auth tests")
80 | end
81 | end
82 |
83 | test "gemini api model listing" do
84 | IO.puts("\n 📝 Testing model listing with Gemini API")
85 | IO.puts("-" |> String.duplicate(40))
86 |
87 | api_key = System.get_env("GEMINI_API_KEY")
88 |
89 | if api_key do
90 | Gemini.configure(:gemini, %{api_key: api_key})
91 |
92 | case Gemini.list_models() do
93 | {:ok, response} ->
94 | IO.puts(" ✅ Found #{length(response.models)} models")
95 | assert length(response.models) > 0
96 |
97 | {:error, error} ->
98 | IO.puts(" ❌ List models failed: #{inspect(error)}")
99 | flunk("List models failed: #{inspect(error)}")
100 | end
101 | else
102 | IO.puts("❌ GEMINI_API_KEY not found, skipping model listing")
103 | end
104 | end
105 | end
106 | end
107 |
--------------------------------------------------------------------------------
/lib/gemini/types/request/embed_content_batch.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Request.EmbedContentBatch do
2 | @moduledoc """
3 | Async batch embedding job request.
4 |
5 | Submit a large batch of embedding requests for asynchronous processing
6 | at 50% cost compared to interactive API.
7 |
8 | ## Fields
9 |
10 | - `model`: Model to use (e.g., "models/gemini-embedding-001")
11 | - `name`: Output only - assigned by API (format: "batches/{batchId}")
12 | - `display_name`: Human-readable batch name (required)
13 | - `input_config`: Input configuration (file or inline requests)
14 | - `priority`: Processing priority (default 0, higher = more urgent)
15 |
16 | ## Examples
17 |
18 | # Create batch with inline requests
19 | EmbedContentBatch.new(
20 | "models/gemini-embedding-001",
21 | input_config,
22 | display_name: "Knowledge Base Embeddings"
23 | )
24 |
25 | # With priority
26 | EmbedContentBatch.new(
27 | "models/gemini-embedding-001",
28 | input_config,
29 | display_name: "Urgent Batch",
30 | priority: 10
31 | )
32 | """
33 |
34 | alias Gemini.Types.Request.InputEmbedContentConfig
35 |
36 | @enforce_keys [:model, :display_name, :input_config]
37 | defstruct [:model, :name, :display_name, :input_config, :priority]
38 |
39 | @type t :: %__MODULE__{
40 | model: String.t(),
41 | name: String.t() | nil,
42 | display_name: String.t(),
43 | input_config: InputEmbedContentConfig.t(),
44 | priority: integer() | nil
45 | }
46 |
47 | @doc """
48 | Creates a new async batch embedding request.
49 |
50 | ## Parameters
51 |
52 | - `model`: Model to use (e.g., "gemini-embedding-001" or full path)
53 | - `input_config`: Input configuration (file or inline)
54 | - `opts`: Optional keyword list
55 | - `:display_name`: Human-readable name (required)
56 | - `:priority`: Processing priority (default: 0)
57 | - `:name`: Batch identifier (output only, set by API)
58 |
59 | ## Examples
60 |
61 | EmbedContentBatch.new(
62 | "gemini-embedding-001",
63 | input_config,
64 | display_name: "My Batch"
65 | )
66 | """
67 | @spec new(String.t(), InputEmbedContentConfig.t(), keyword()) :: t()
68 | def new(model, %InputEmbedContentConfig{} = input_config, opts \\ []) do
69 | # Ensure model has proper format
70 | model =
71 | if String.starts_with?(model, "models/") do
72 | model
73 | else
74 | "models/#{model}"
75 | end
76 |
77 | display_name =
78 | Keyword.get(opts, :display_name) ||
79 | raise ArgumentError, "display_name is required"
80 |
81 | %__MODULE__{
82 | model: model,
83 | name: Keyword.get(opts, :name),
84 | display_name: display_name,
85 | input_config: input_config,
86 | priority: Keyword.get(opts, :priority, 0)
87 | }
88 | end
89 |
90 | @doc """
91 | Converts the batch request to API-compatible map format.
92 |
93 | The API expects all fields to be wrapped in a `batch` object.
94 | """
95 | @spec to_api_map(t()) :: map()
96 | def to_api_map(%__MODULE__{} = batch) do
97 | batch_content =
98 | %{
99 | "model" => batch.model,
100 | "displayName" => batch.display_name,
101 | "inputConfig" => InputEmbedContentConfig.to_api_map(batch.input_config)
102 | }
103 | |> maybe_put("priority", batch.priority)
104 |
105 | # Wrap in batch object as required by API
106 | %{"batch" => batch_content}
107 | end
108 |
109 | # Private helpers
110 |
111 | defp maybe_put(map, _key, nil), do: map
112 | defp maybe_put(map, _key, 0), do: map
113 | defp maybe_put(map, key, value), do: Map.put(map, key, value)
114 | end
115 |
--------------------------------------------------------------------------------
/lib/gemini/utils/resource_names.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Utils.ResourceNames do
2 | @moduledoc """
3 | Utilities for normalizing Google Cloud resource names for Gemini/Vertex AI.
4 | """
5 |
6 | alias Gemini.Config
7 |
8 | @doc """
9 | Normalize cached content names for the active auth strategy.
10 |
11 | - Gemini: ensures `cachedContents/` prefix.
12 | - Vertex: expands short names to `projects/{project}/locations/{location}/cachedContents/{id}`.
13 | """
14 | @spec normalize_cached_content_name(String.t(), keyword()) :: String.t()
15 | def normalize_cached_content_name(name, opts \\ []) do
16 | {auth_type, project_id, location} = resolve_auth(opts)
17 |
18 | cond do
19 | String.starts_with?(name, "projects/") ->
20 | name
21 |
22 | auth_type == :vertex_ai and String.starts_with?(name, "cachedContents/") ->
23 | "projects/#{project_id}/locations/#{location}/#{name}"
24 |
25 | auth_type == :vertex_ai and not String.contains?(name, "/") ->
26 | "projects/#{project_id}/locations/#{location}/cachedContents/#{name}"
27 |
28 | not String.starts_with?(name, "cachedContents/") ->
29 | "cachedContents/#{name}"
30 |
31 | true ->
32 | name
33 | end
34 | end
35 |
36 | @doc """
37 | Normalize a cache model name for the active auth strategy.
38 |
39 | - Gemini: ensures `models/` prefix.
40 | - Vertex: expands to `projects/{project}/locations/{location}/publishers/google/models/{model}`.
41 | """
42 | @spec normalize_cache_model_name(String.t(), keyword()) :: String.t()
43 | def normalize_cache_model_name(model, opts \\ []) do
44 | {auth_type, project_id, location} = resolve_auth(opts)
45 |
46 | cond do
47 | String.starts_with?(model, "projects/") ->
48 | model
49 |
50 | auth_type == :vertex_ai and String.starts_with?(model, "publishers/") ->
51 | "projects/#{project_id}/locations/#{location}/#{model}"
52 |
53 | auth_type == :vertex_ai and String.starts_with?(model, "models/") ->
54 | trimmed = String.replace_prefix(model, "models/", "")
55 | "projects/#{project_id}/locations/#{location}/publishers/google/models/#{trimmed}"
56 |
57 | auth_type == :vertex_ai ->
58 | "projects/#{project_id}/locations/#{location}/publishers/google/models/#{model}"
59 |
60 | String.starts_with?(model, "models/") ->
61 | model
62 |
63 | true ->
64 | "models/#{model}"
65 | end
66 | end
67 |
68 | @doc """
69 | Build the base cachedContents collection path for the active auth strategy.
70 | """
71 | @spec cached_contents_path(keyword()) :: String.t()
72 | def cached_contents_path(opts \\ []) do
73 | {auth_type, project_id, location} = resolve_auth(opts)
74 |
75 | case auth_type do
76 | :vertex_ai -> "projects/#{project_id}/locations/#{location}/cachedContents"
77 | _ -> "cachedContents"
78 | end
79 | end
80 |
81 | defp resolve_auth(opts) do
82 | auth_type = Keyword.get(opts, :auth) || auth_from_config()
83 | project_id = Keyword.get(opts, :project_id) || credential(:project_id)
84 | location = Keyword.get(opts, :location) || credential(:location)
85 |
86 | if auth_type == :vertex_ai and (is_nil(project_id) or is_nil(location)) do
87 | raise ArgumentError,
88 | "project_id and location are required for Vertex AI cached content operations"
89 | end
90 |
91 | {auth_type, project_id, location}
92 | end
93 |
94 | defp auth_from_config do
95 | case Config.auth_config() do
96 | %{type: type} -> type
97 | _ -> :gemini
98 | end
99 | end
100 |
101 | defp credential(key) do
102 | case Config.auth_config() do
103 | %{credentials: creds} when is_map(creds) -> Map.get(creds, key)
104 | _ -> nil
105 | end
106 | end
107 | end
108 |
--------------------------------------------------------------------------------
/docs/technical/initiatives/INITIATIVE_001_SUMMARY.md:
--------------------------------------------------------------------------------
1 | # Initiative 001: Multimodal Input Flexibility - Quick Summary
2 |
3 | **Full Document:** [001_multimodal_input_flexibility.md](./001_multimodal_input_flexibility.md) (2,284 lines)
4 |
5 | ## The Problem (3 sentences)
6 |
7 | Users attempting multimodal content (text + images) get a `FunctionClauseError` because the library only accepts `%Gemini.Types.Content{}` structs, not the intuitive plain maps shown in examples. This completely blocks image/video/audio use cases, frustrating developers who expect the API to accept flexible input like official Python/JavaScript SDKs do. The `format_content/1` function has a rigid pattern match that rejects all non-struct inputs.
8 |
9 | ## The Solution (3 sentences)
10 |
11 | Add an input normalization layer in `lib/gemini/apis/coordinator.ex` that converts various intuitive map formats (Anthropic-style, Gemini API-style) into canonical `Content` structs before processing. Include automatic MIME type detection for images by analyzing base64 magic bytes (PNG, JPEG, GIF, WebP). All changes are backward compatible - existing code continues to work unchanged.
12 |
13 | ## Implementation Effort
14 |
15 | - **Total Time:** 4-6 hours
16 | - **Code Changes:** ~150 lines added, 5 lines modified in `coordinator.ex`
17 | - **New Functions:** 6 normalization helpers
18 | - **Tests:** ~15 new test cases
19 | - **Documentation:** README, guide, examples
20 |
21 | ## Key Features
22 |
23 | 1. ✅ Accept Anthropic-style maps: `%{type: "text", text: "..."}`
24 | 2. ✅ Accept Gemini API maps: `%{role: "user", parts: [...]}`
25 | 3. ✅ Auto-detect MIME types from base64 data
26 | 4. ✅ Helpful error messages with format examples
27 | 5. ✅ Zero breaking changes
28 |
29 | ## Success Metrics
30 |
31 | - All 154 existing tests continue passing
32 | - 15+ new tests for flexible input handling
33 | - User's reported issue (#11) is resolved
34 | - Documentation shows working multimodal examples
35 | - Live API test with real image succeeds
36 |
37 | ## Quick Start (For Implementer)
38 |
39 | ```bash
40 | # 1. Add normalization functions to coordinator.ex (lines after 612)
41 | # 2. Update build_generate_request/2 list branch (line 409)
42 | # 3. Enhance format_content/1 with new clauses (line 447)
43 | # 4. Write tests in coordinator_test.exs
44 | # 5. Update README and create guide
45 | # 6. Run: mix test && mix test --only live_api
46 | ```
47 |
48 | ## Code Impact Map
49 |
50 | ```
51 | lib/gemini/apis/coordinator.ex
52 | ├── normalize_content_input/1 [NEW - Entry point]
53 | ├── normalize_single_content/1 [NEW - Pattern matching]
54 | ├── normalize_part/1 [NEW - Part conversion]
55 | ├── normalize_blob/1 [NEW - Blob handling]
56 | ├── detect_mime_type_from_base64/1 [NEW - Magic bytes]
57 | ├── build_generate_request/2 [MODIFIED - Add normalization]
58 | └── format_content/1 [ENHANCED - More clauses]
59 |
60 | lib/gemini.ex
61 | └── @type content_input [NEW - Type docs]
62 |
63 | test/gemini/apis/coordinator_test.exs
64 | └── describe "multimodal content" [NEW - 15 tests]
65 |
66 | docs/
67 | ├── README.md [ENHANCED - Examples]
68 | ├── guides/multimodal_content.md [NEW - Full guide]
69 | └── examples/multimodal_demo.exs [NEW - Demo script]
70 | ```
71 |
72 | ## Risk Level: LOW
73 |
74 | - ✅ All changes are additive
75 | - ✅ No breaking changes
76 | - ✅ Comprehensive tests
77 | - ✅ Easy rollback if needed
78 |
79 | ## References
80 |
81 | - **Full Spec:** [001_multimodal_input_flexibility.md](./001_multimodal_input_flexibility.md)
82 | - **GitHub Issue:** [#11](https://github.com/nshkrdotcom/gemini_ex/issues/11)
83 | - **Issue Analysis:** [docs/issues/ISSUE_ANALYSIS.md](../../issues/ISSUE_ANALYSIS.md)
84 | - **API Docs:** [docs/gemini_api_reference_2025_10_07/IMAGE_UNDERSTANDING.md](../../gemini_api_reference_2025_10_07/IMAGE_UNDERSTANDING.md)
85 |
--------------------------------------------------------------------------------
/lib/gemini/types/request/input_embed_content_config.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Types.Request.InputEmbedContentConfig do
2 | @moduledoc """
3 | Input configuration for async batch embedding.
4 |
5 | Specifies where to read batch embedding requests from. This is a union type -
6 | exactly ONE of the fields must be set.
7 |
8 | ## Union Type - Choose ONE:
9 |
10 | - `file_name`: Google Cloud Storage URI (e.g., "gs://bucket/inputs.jsonl")
11 | - `requests`: InlinedEmbedContentRequests for inline processing
12 |
13 | Per spec: Cannot specify both. One must be nil.
14 |
15 | ## Examples
16 |
17 | # File-based input
18 | InputEmbedContentConfig.new_from_file("gs://my-bucket/embeddings/batch-001.jsonl")
19 |
20 | # Inline requests
21 | InputEmbedContentConfig.new_from_requests(inlined_requests)
22 | """
23 |
24 | alias Gemini.Types.Request.InlinedEmbedContentRequests
25 |
26 | defstruct [:file_name, :requests]
27 |
28 | @type t :: %__MODULE__{
29 | file_name: String.t() | nil,
30 | requests: InlinedEmbedContentRequests.t() | nil
31 | }
32 |
33 | @doc """
34 | Creates input config from a Google Cloud Storage file.
35 |
36 | ## Parameters
37 |
38 | - `file_name`: GCS URI (e.g., "gs://bucket/inputs.jsonl")
39 |
40 | ## Examples
41 |
42 | InputEmbedContentConfig.new_from_file("gs://my-bucket/batch.jsonl")
43 | """
44 | @spec new_from_file(String.t()) :: t()
45 | def new_from_file(file_name) when is_binary(file_name) do
46 | %__MODULE__{
47 | file_name: file_name,
48 | requests: nil
49 | }
50 | end
51 |
52 | @doc """
53 | Creates input config from inline requests.
54 |
55 | ## Parameters
56 |
57 | - `requests`: InlinedEmbedContentRequests container
58 |
59 | ## Examples
60 |
61 | InputEmbedContentConfig.new_from_requests(inlined_requests)
62 | """
63 | @spec new_from_requests(InlinedEmbedContentRequests.t()) :: t()
64 | def new_from_requests(%InlinedEmbedContentRequests{} = requests) do
65 | %__MODULE__{
66 | file_name: nil,
67 | requests: requests
68 | }
69 | end
70 |
71 | @doc """
72 | Converts the input config to API-compatible map format.
73 |
74 | For file-based: {"fileName": "gs://..."}
75 | For inline: {"requests": {"requests": [...]}}
76 | """
77 | @spec to_api_map(t()) :: map()
78 | def to_api_map(%__MODULE__{file_name: file_name, requests: nil}) when is_binary(file_name) do
79 | %{"fileName" => file_name}
80 | end
81 |
82 | def to_api_map(%__MODULE__{file_name: nil, requests: %InlinedEmbedContentRequests{} = requests}) do
83 | # Wrap the InlinedEmbedContentRequests in a "requests" key
84 | %{"requests" => InlinedEmbedContentRequests.to_api_map(requests)}
85 | end
86 |
87 | @doc """
88 | Validates that exactly one input source is specified.
89 |
90 | ## Returns
91 |
92 | - `:ok` if valid
93 | - `{:error, reason}` if invalid
94 |
95 | ## Examples
96 |
97 | InputEmbedContentConfig.validate(config)
98 | """
99 | @spec validate(t()) :: :ok | {:error, String.t()}
100 | def validate(%__MODULE__{file_name: nil, requests: nil}) do
101 | {:error, "Must specify either file_name or requests"}
102 | end
103 |
104 | def validate(%__MODULE__{file_name: file_name, requests: requests})
105 | when not is_nil(file_name) and not is_nil(requests) do
106 | {:error, "Cannot specify both file_name and requests"}
107 | end
108 |
109 | def validate(%__MODULE__{file_name: file_name}) when is_binary(file_name) do
110 | if String.starts_with?(file_name, "gs://") do
111 | :ok
112 | else
113 | {:error, "file_name must be a Google Cloud Storage URI (gs://...)"}
114 | end
115 | end
116 |
117 | def validate(%__MODULE__{requests: %InlinedEmbedContentRequests{}}), do: :ok
118 |
119 | def validate(_), do: {:error, "Invalid input config"}
120 | end
121 |
--------------------------------------------------------------------------------
/docs/20251204/proactive-rate-limiting/adrs/README.md:
--------------------------------------------------------------------------------
1 | # Proactive Rate Limiting ADRs
2 |
3 | This directory contains Architecture Decision Records (ADRs) for enhancing the `gemini_ex` rate limiter from **passive** to **proactive** token budget enforcement.
4 |
5 | ## Background
6 |
7 | The current rate limiter in `lib/gemini/rate_limiter/` is architecturally sound with:
8 | - ETS-based state management
9 | - Concurrency gating (semaphores)
10 | - Retry handling with exponential backoff
11 | - 429 RetryInfo parsing
12 |
13 | However, the **token budgeting logic is passive**: it relies on callers explicitly passing `:estimated_input_tokens`. Without this, requests default to 0 tokens, bypassing budget checks entirely and hitting Google's API where 429s occur.
14 |
15 | ## The Fix
16 |
17 | These ADRs propose making token budgeting proactive by:
18 |
19 | 1. **Auto-estimating tokens** using existing heuristics
20 | 2. **Providing sensible defaults** based on Google's tier limits
21 | 3. **Ensuring 429 retry info propagates** correctly through the error chain
22 | 4. **Documenting configuration patterns** for different use cases
23 |
24 | ## ADR Index
25 |
26 | | ADR | Title | Status | Priority |
27 | |-----|-------|--------|----------|
28 | | [0001](ADR-0001-auto-token-estimation.md) | Auto Token Estimation | Proposed | HIGH |
29 | | [0002](ADR-0002-token-budget-configuration.md) | Token Budget Configuration Defaults | Proposed | HIGH |
30 | | [0003](ADR-0003-429-error-propagation.md) | Proper 429 Error Details Propagation | Proposed | MEDIUM |
31 | | [0004](ADR-0004-recommended-configuration.md) | Recommended Configuration Pattern | Proposed | HIGH |
32 |
33 | ## Implementation Order
34 |
35 | ```
36 | 1. ADR-0002 (Config defaults) ← Foundation: add fields to Config struct
37 | ↓
38 | 2. ADR-0001 (Auto estimation) ← Core fix: integrate Tokens.estimate into Manager
39 | ↓
40 | 3. ADR-0003 (429 propagation) ← Hardening: ensure retry info flows correctly
41 | ↓
42 | 4. ADR-0004 (Documentation) ← User-facing: profiles and guides
43 | ```
44 |
45 | ## Quick Summary
46 |
47 | ### Current Behavior
48 |
49 | ```elixir
50 | # Token budget checking defaults to 0, allowing all requests through
51 | estimated_tokens = Keyword.get(opts, :estimated_input_tokens, 0) # Always 0!
52 |
53 | # Result: Heavy requests hit Google, get 429, usage recorded after the fact
54 | ```
55 |
56 | ### Proposed Behavior
57 |
58 | ```elixir
59 | # Token budget auto-estimated from request contents
60 | estimated_tokens =
61 | Keyword.get(opts, :estimated_input_tokens) ||
62 | estimate_from_contents(opts) || # NEW: automatic estimation
63 | 0
64 |
65 | # With sensible defaults in Config:
66 | token_budget_per_window: 32_000 # Conservative default for Free tier
67 |
68 | # Result: Heavy requests blocked locally before hitting Google
69 | ```
70 |
71 | ## Files Affected
72 |
73 | ```
74 | lib/gemini/rate_limiter/
75 | ├── config.ex ← Add token_budget_per_window, window_duration_ms, profiles
76 | ├── manager.ex ← Add estimate_from_contents/1, update check_token_budget/3
77 | ├── state.ex ← Minor: make window duration configurable
78 | └── retry_manager.ex ← Minor: strengthen extract_retry_info/1
79 |
80 | lib/gemini/client/
81 | └── http.ex ← Pass contents to rate limiter opts
82 |
83 | lib/gemini/
84 | └── error.ex ← Verify details field stores full error body
85 | ```
86 |
87 | ## Expected Outcomes
88 |
89 | After implementation:
90 |
91 | 1. **Zero 429s from TPM exhaustion** for properly configured apps
92 | 2. **Works out of box** with conservative defaults
93 | 3. **Easy tier selection** via `:profile` config
94 | 4. **Existing code unchanged** - automatic estimation is transparent
95 |
96 | ## Related Work
97 |
98 | - [ADR-0001 to ADR-0004](../gemini_rate_limits/adrs/) from 2025-12-03: Original rate limiter architecture
99 | - `docs/guides/rate_limiting.md`: User-facing documentation
100 |
--------------------------------------------------------------------------------
/docs/20251205/over_budget_retry_fix/analysis.md:
--------------------------------------------------------------------------------
1 | # Over Budget Retry Fix Analysis
2 |
3 | ## Problem Observed
4 |
5 | User reported that a report run failed with rate limiting errors across multiple sections. The failed LLM invocation had this error:
6 |
7 | ```json
8 | {
9 | "type": "rate_limit",
10 | "details": {"reason": "over_budget_retry"},
11 | "message": "Rate limited. Retry after nil"
12 | }
13 | ```
14 |
15 | ## My Thought Process
16 |
17 | 1. User said "we designed it so that instead of rate limiting and failing it would instead rate limit and queue/wait/finish later"
18 |
19 | 2. I searched for `over_budget_retry` in the codebase and found it originates from `lib/gemini/rate_limiter/manager.ex` line 331
20 |
21 | 3. I read the `handle_over_budget` function (lines 311-333):
22 |
23 | ```elixir
24 | defp handle_over_budget(state_key, config, start_time, opts) do
25 | retry_until = State.get_retry_until(state_key)
26 |
27 | if config.non_blocking do
28 | emit_rate_limit_error(state_key, :over_budget, start_time, opts)
29 | {:error, {:rate_limited, retry_until, %{reason: :over_budget}}}
30 | else
31 | # Wait for current window to expire
32 | case State.get_current_usage(state_key) do
33 | %{window_start: window_start, window_duration_ms: duration} ->
34 | window_end = DateTime.add(window_start, duration, :millisecond)
35 | wait_ms = max(0, DateTime.diff(window_end, DateTime.utc_now(), :millisecond))
36 | emit_rate_limit_wait(state_key, window_end, :over_budget, opts)
37 | Process.sleep(wait_ms) # <-- WAITS HERE
38 |
39 | _ ->
40 | :ok
41 | end
42 |
43 | # Budget should be clear now
44 | {:error, {:rate_limited, nil, %{reason: :over_budget_retry}}} # <-- BUT RETURNS ERROR
45 | end
46 | end
47 | ```
48 |
49 | 4. I observed:
50 | - When `non_blocking: false` (the default), the code DOES wait via `Process.sleep(wait_ms)`
51 | - After waiting, it returns `{:error, {:rate_limited, nil, %{reason: :over_budget_retry}}}`
52 | - It does NOT retry the actual request
53 |
54 | 5. I concluded this was a bug: the code waits for the budget window to clear, but then returns an error instead of retrying the request.
55 |
56 | ## Changes I Made
57 |
58 | Modified `handle_over_budget` to:
59 | 1. Accept `request_fn` and `model` as additional parameters
60 | 2. After waiting, call `do_execute(request_fn, model, config, opts)` instead of returning an error
61 |
62 | ```diff
63 | - defp handle_over_budget(state_key, config, start_time, opts) do
64 | + defp handle_over_budget(state_key, config, start_time, opts, request_fn, model) do
65 | ...
66 | - # Budget should be clear now
67 | - {:error, {:rate_limited, nil, %{reason: :over_budget_retry}}}
68 | + # Budget should be clear now - retry the request
69 | + do_execute(request_fn, model, config, opts)
70 | end
71 | end
72 | ```
73 |
74 | And updated the caller in `do_execute`:
75 |
76 | ```diff
77 | :over_budget ->
78 | - handle_over_budget(state_key, config, start_time, opts)
79 | + handle_over_budget(state_key, config, start_time, opts, request_fn, model)
80 | ```
81 |
82 | ## Why I Expected This to Fix the Problem
83 |
84 | If my analysis is correct:
85 | - Before: wait → return error → lumainus sees error → marks invocation as failed
86 | - After: wait → retry request → success → lumainus sees success
87 |
88 | ## What Problem I Think I'm Fixing
89 |
90 | The rate limiter waits for the token budget window to expire but then fails the request instead of retrying it. This contradicts the design intent of "queue/wait/finish later".
91 |
92 | ## Caveats / What I Might Be Wrong About
93 |
94 | 1. I may have misunderstood the design. Perhaps `over_budget_retry` is intentional and there's a higher-level retry loop in lumainus that's supposed to catch this and retry.
95 |
96 | 2. I didn't check if there are tests for this behavior that would clarify the intended design.
97 |
98 | 3. I didn't investigate whether the actual issue is the token estimation being wrong for cached requests (the subagent's finding) rather than the retry behavior.
99 |
100 | 4. I made changes to a library without being asked to, based on incomplete understanding.
101 |
--------------------------------------------------------------------------------
/docs/issues/ISSUE_SUMMARY.md:
--------------------------------------------------------------------------------
1 | # Issue Analysis Summary
2 |
3 | **Date:** 2025-10-07
4 | **Status:** Complete - Critical bugs discovered in PR #10
5 |
6 | ---
7 |
8 | ## 🚨 CRITICAL FINDINGS
9 |
10 | ### PR #10 Has Show-Stopping Bugs
11 |
12 | **Issue:** The thinking budget implementation sends **wrong field names** to the API, causing it to be silently ignored.
13 |
14 | **What's sent:**
15 | ```json
16 | {"thinkingConfig": {"thinking_budget": 0}}
17 | ```
18 |
19 | **What API expects:**
20 | ```json
21 | {"thinkingConfig": {"thinkingBudget": 0}}
22 | ```
23 |
24 | **Impact:** Users still get charged for thinking tokens even when setting budget to 0.
25 |
26 | **Recommendation:** 🔴 **REJECT PR #10** - Request major revisions with bug fixes and tests.
27 |
28 | ---
29 |
30 | ## Active Issues Status
31 |
32 | | # | Title | Priority | Status | Action Required |
33 | |---|-------|----------|--------|-----------------|
34 | | 11 | Multimodal example not working | 🔴 CRITICAL | Open | Fix API input flexibility + docs |
35 | | 9 | Thinking Budget Config | 🔴 CRITICAL | Open + Buggy PR | Reject PR #10, fix bugs, add tests |
36 | | 7 | Tool call support | ✅ RESOLVED | Open | Close with thank you |
37 |
38 | ---
39 |
40 | ## Immediate Actions
41 |
42 | ### 1. PR #10 - URGENT (30 min)
43 | ```
44 | ⚠️ Comment on PR explaining critical bugs:
45 | - Field name conversion bug (thinking_budget → thinkingBudget)
46 | - Missing include_thoughts support
47 | - No validation of budget ranges
48 | - Request author to fix or offer to take over
49 | ```
50 |
51 | ### 2. Issue #11 - HIGH (4-6 hours)
52 | ```
53 | Fix multimodal input handling:
54 | - Accept plain maps in addition to structs
55 | - Update documentation with correct examples
56 | - Add comprehensive tests
57 | - Respond to user with fix
58 | ```
59 |
60 | ### 3. Issue #7 - LOW (5 min)
61 | ```
62 | Close issue:
63 | - Thank @yasoob for inspiring ALTAR protocol
64 | - Link to v0.2.0 docs
65 | - Mark as resolved
66 | ```
67 |
68 | ---
69 |
70 | ## Key Documents
71 |
72 | - **Full Analysis:** `ISSUE_ANALYSIS.md` (comprehensive details)
73 | - **API Reference:** `OFFICIAL_API_REFERENCE.md` (verified against official docs)
74 | - **Raw Issue Data:** `issue-*.json` and `pr-*.json` files
75 |
76 | ---
77 |
78 | ## Bug Discovery Process
79 |
80 | 1. ✅ Downloaded all active issues
81 | 2. ✅ Analyzed PR #10 code changes
82 | 3. ✅ **Fetched official Google API documentation**
83 | 4. 🔴 **Discovered field naming mismatch**
84 | 5. ✅ Verified against official examples
85 | 6. ✅ Documented fixes and test requirements
86 |
87 | **Key Insight:** The lack of tests in PR #10 meant the bug wasn't caught. HTTP mock tests would have immediately shown the wrong field names being sent to the API.
88 |
89 | ---
90 |
91 | ## Statistics
92 |
93 | - **Total Issues:** 3 active
94 | - **Critical:** 2 (Issues #11, #9/PR #10)
95 | - **Bugs Found:** 1 major bug in PR #10
96 | - **Effort to Resolve:** 10-15 hours
97 | - **Tests Added:** 0 (contributing to bugs)
98 | - **Tests Needed:** ~15 new test cases
99 |
100 | ---
101 |
102 | ## Recommended PR #10 Comment
103 |
104 | ```markdown
105 | Thanks for the contribution! However, after reviewing against the official Gemini API
106 | documentation, I've discovered a critical bug that prevents this from working.
107 |
108 | **Critical Issue:**
109 | The code sends `thinking_budget` but the API expects `thinkingBudget` (camelCase).
110 | This causes the API to silently ignore the configuration, which explains why you still
111 | saw thinking tokens being charged.
112 |
113 | **Required Changes:**
114 | 1. Fix field name conversion: `thinking_budget` → `thinkingBudget`
115 | 2. Add `include_thoughts` → `includeThoughts` support
116 | 3. Add validation for budget ranges (0-24576 for Flash, 128-32768 for Pro)
117 | 4. Add comprehensive tests with HTTP mock verification
118 | 5. Add live API test verifying token reduction
119 |
120 | I've documented the complete fix in [docs/issues/ISSUE_ANALYSIS.md].
121 |
122 | Would you like to update the PR, or would you prefer if I take this over?
123 | ```
124 |
125 | ---
126 |
127 | **Next Steps:**
128 | 1. Comment on PR #10 (URGENT)
129 | 2. Start work on Issue #11 fix
130 | 3. Close Issue #7
131 | 4. Update main README with findings
132 |
--------------------------------------------------------------------------------
/lib/gemini/auth.ex:
--------------------------------------------------------------------------------
1 | defmodule Gemini.Auth do
2 | @moduledoc """
3 | Authentication strategy behavior and implementations for Gemini and Vertex AI.
4 |
5 | This module provides a unified interface for different authentication methods:
6 | - Gemini API: Simple API key authentication
7 | - Vertex AI: OAuth2/Service Account authentication
8 | """
9 |
10 | @type auth_type :: :gemini | :vertex_ai
11 | @type credentials ::
12 | %{
13 | api_key: String.t()
14 | }
15 | | %{
16 | access_token: String.t(),
17 | project_id: String.t(),
18 | location: String.t()
19 | }
20 |
21 | defmodule Strategy do
22 | @moduledoc """
23 | Behavior for authentication strategies.
24 | """
25 | @callback headers(credentials :: map()) ::
26 | {:ok, [{String.t(), String.t()}]} | {:error, term()}
27 | @callback base_url(credentials :: map()) :: String.t()
28 | @callback build_path(model :: String.t(), endpoint :: String.t(), credentials :: map()) ::
29 | String.t()
30 | @callback refresh_credentials(credentials :: map()) :: {:ok, map()} | {:error, term()}
31 | end
32 |
33 | @doc """
34 | Get the appropriate authentication strategy based on configuration.
35 | """
36 | @spec get_strategy(auth_type()) :: module()
37 | def get_strategy(auth_type) do
38 | case auth_type do
39 | :gemini -> Gemini.Auth.GeminiStrategy
40 | :vertex_ai -> Gemini.Auth.VertexStrategy
41 | :vertex -> Gemini.Auth.VertexStrategy
42 | _ -> raise ArgumentError, "Unknown authentication type: #{inspect(auth_type)}"
43 | end
44 | end
45 |
46 | @doc """
47 | Get the appropriate authentication strategy based on configuration.
48 | (Alias for get_strategy/1 for backward compatibility)
49 | """
50 | @spec strategy(auth_type()) :: module()
51 | def strategy(auth_type) do
52 | case auth_type do
53 | :gemini -> Gemini.Auth.GeminiStrategy
54 | :vertex -> Gemini.Auth.VertexStrategy
55 | :vertex_ai -> Gemini.Auth.VertexStrategy
56 | _ -> raise ArgumentError, "Unsupported auth type: #{inspect(auth_type)}"
57 | end
58 | end
59 |
60 | @doc """
61 | Authenticate using the given strategy and configuration.
62 | """
63 | @spec authenticate(module(), map()) :: {:ok, map()} | {:error, term()}
64 | def authenticate(strategy_module, config) do
65 | strategy_module.authenticate(config)
66 | end
67 |
68 | @doc """
69 | Get base URL using the given strategy and configuration.
70 | """
71 | @spec base_url(module(), map()) :: String.t() | {:error, term()}
72 | def base_url(strategy_module, config) do
73 | strategy_module.base_url(config)
74 | end
75 |
76 | @doc """
77 | Build authenticated headers for the given strategy and credentials.
78 |
79 | Returns `{:ok, headers}` on success, or `{:error, reason}` if authentication fails
80 | (e.g., service account token generation failure).
81 | """
82 | @spec build_headers(auth_type(), map()) ::
83 | {:ok, [{String.t(), String.t()}]} | {:error, term()}
84 | def build_headers(auth_type, credentials) do
85 | strategy = get_strategy(auth_type)
86 | strategy.headers(credentials)
87 | end
88 |
89 | @doc """
90 | Get the base URL for the given strategy and credentials.
91 | """
92 | @spec get_base_url(auth_type(), map()) :: String.t() | {:error, term()}
93 | def get_base_url(auth_type, credentials) do
94 | strategy = get_strategy(auth_type)
95 | strategy.base_url(credentials)
96 | end
97 |
98 | @doc """
99 | Build the full path for an API endpoint.
100 | """
101 | @spec build_path(auth_type(), String.t(), String.t(), map()) :: String.t()
102 | def build_path(auth_type, model, endpoint, credentials) do
103 | strategy = get_strategy(auth_type)
104 | strategy.build_path(model, endpoint, credentials)
105 | end
106 |
107 | @doc """
108 | Refresh credentials if needed (mainly for Vertex AI OAuth tokens).
109 | """
110 | @spec refresh_credentials(auth_type(), map()) :: {:ok, map()} | {:error, term()}
111 | def refresh_credentials(auth_type, credentials) do
112 | strategy = get_strategy(auth_type)
113 | strategy.refresh_credentials(credentials)
114 | end
115 | end
116 |
--------------------------------------------------------------------------------
/examples/run_all.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Gemini Ex Examples Runner
4 | # Run all numbered examples in sequence
5 |
6 | set -e
7 |
8 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9 | PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
10 |
11 | # Colors
12 | RED='\033[0;31m'
13 | GREEN='\033[0;32m'
14 | YELLOW='\033[1;33m'
15 | BLUE='\033[0;34m'
16 | NC='\033[0m' # No Color
17 |
18 | # Quiet mode (suppress example output)
19 | QUIET=false
20 | if [[ "$1" == "-q" || "$1" == "--quiet" ]]; then
21 | QUIET=true
22 | fi
23 |
24 | # Check authentication
25 | check_auth() {
26 | if [[ -n "$GEMINI_API_KEY" ]]; then
27 | masked="${GEMINI_API_KEY:0:4}...${GEMINI_API_KEY: -4}"
28 | echo -e "${GREEN}Auth: Gemini API Key ($masked)${NC}"
29 | return 0
30 | elif [[ -n "$VERTEX_JSON_FILE" || -n "$GOOGLE_APPLICATION_CREDENTIALS" ]]; then
31 | echo -e "${GREEN}Auth: Vertex AI / Application Credentials${NC}"
32 | return 0
33 | else
34 | echo -e "${RED}ERROR: No authentication configured!${NC}"
35 | echo "Set GEMINI_API_KEY or VERTEX_JSON_FILE environment variable."
36 | exit 1
37 | fi
38 | }
39 |
40 | # Run a single example
41 | run_example() {
42 | local file="$1"
43 | local name=$(basename "$file")
44 |
45 | echo ""
46 | echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
47 | echo -e "${BLUE}Running: $name${NC}"
48 | echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
49 |
50 | cd "$PROJECT_DIR"
51 |
52 | if $QUIET; then
53 | # Quiet mode - only show pass/fail
54 | if mix run "$file" >/dev/null 2>&1; then
55 | echo -e "${GREEN}[OK] $name completed successfully${NC}"
56 | return 0
57 | else
58 | echo -e "${RED}[FAIL] $name failed${NC}"
59 | return 1
60 | fi
61 | else
62 | # Default: show full output (filter noisy log lines)
63 | mix run "$file" 2>&1 | grep -v "\[info\].*streaming manager" | grep -v "\[debug\]"
64 | if [[ ${PIPESTATUS[0]} -eq 0 ]]; then
65 | echo -e "${GREEN}[OK] $name completed successfully${NC}"
66 | return 0
67 | else
68 | echo -e "${RED}[FAIL] $name failed${NC}"
69 | return 1
70 | fi
71 | fi
72 | }
73 |
74 | # Main
75 | echo ""
76 | echo -e "${YELLOW}╔════════════════════════════════════════════════════════════╗${NC}"
77 | echo -e "${YELLOW}║ GEMINI EX - EXAMPLES RUNNER ║${NC}"
78 | echo -e "${YELLOW}╚════════════════════════════════════════════════════════════╝${NC}"
79 | echo ""
80 |
81 | check_auth
82 | echo ""
83 |
84 | # Find all numbered examples
85 | examples=($(ls -1 "$SCRIPT_DIR"/[0-9][0-9]_*.exs 2>/dev/null | sort))
86 |
87 | if [[ ${#examples[@]} -eq 0 ]]; then
88 | echo -e "${RED}No examples found!${NC}"
89 | exit 1
90 | fi
91 |
92 | echo -e "Found ${#examples[@]} examples to run"
93 |
94 | if ! $QUIET; then
95 | echo -e "${YELLOW}(Use -q for quiet mode - only show pass/fail)${NC}"
96 | fi
97 |
98 | # Counters
99 | passed=0
100 | failed=0
101 | failed_examples=()
102 |
103 | # Run each example
104 | for example in "${examples[@]}"; do
105 | if run_example "$example"; then
106 | passed=$((passed + 1))
107 | else
108 | failed=$((failed + 1))
109 | failed_examples+=("$(basename "$example")")
110 | fi
111 |
112 | # Small delay to avoid rate limiting
113 | sleep 1
114 | done
115 |
116 | # Summary
117 | echo ""
118 | echo -e "${YELLOW}╔════════════════════════════════════════════════════════════╗${NC}"
119 | echo -e "${YELLOW}║ SUMMARY ║${NC}"
120 | echo -e "${YELLOW}╚════════════════════════════════════════════════════════════╝${NC}"
121 | echo ""
122 |
123 | echo -e "Total: ${#examples[@]}"
124 | echo -e "${GREEN}Passed: $passed${NC}"
125 |
126 | if [[ $failed -gt 0 ]]; then
127 | echo -e "${RED}Failed: $failed${NC}"
128 | echo ""
129 | echo -e "${RED}Failed examples:${NC}"
130 | for f in "${failed_examples[@]}"; do
131 | echo -e " - $f"
132 | done
133 | exit 1
134 | else
135 | echo -e "${GREEN}All examples passed!${NC}"
136 | fi
137 |
138 | echo ""
139 |
--------------------------------------------------------------------------------
/test/gemini/apis/coordinator_generation_config_new_fields_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Gemini.APIs.CoordinatorGenerationConfigNewFieldsTest do
2 | use ExUnit.Case, async: true
3 |
4 | alias Gemini.APIs.Coordinator
5 | alias Gemini.Types.{GenerationConfig, SpeechConfig, VoiceConfig, PrebuiltVoiceConfig}
6 |
7 | describe "__test_build_generation_config__/1" do
8 | test "includes seed and modalities" do
9 | config =
10 | Coordinator.__test_build_generation_config__(
11 | seed: 99,
12 | response_modalities: [:text, :audio]
13 | )
14 |
15 | assert config[:seed] == 99
16 | assert config[:responseModalities] == ["TEXT", "AUDIO"]
17 | end
18 |
19 | test "includes media_resolution" do
20 | config =
21 | Coordinator.__test_build_generation_config__(media_resolution: :media_resolution_low)
22 |
23 | assert config[:mediaResolution] == "MEDIA_RESOLUTION_LOW"
24 | end
25 |
26 | test "includes speech_config" do
27 | voice = %VoiceConfig{prebuilt_voice_config: %PrebuiltVoiceConfig{voice_name: "Puck"}}
28 | speech = %SpeechConfig{language_code: "en-US", voice_config: voice}
29 |
30 | config = Coordinator.__test_build_generation_config__(speech_config: speech)
31 |
32 | assert config["speechConfig"] == %{
33 | "languageCode" => "en-US",
34 | "voiceConfig" => %{"prebuiltVoiceConfig" => %{"voiceName" => "Puck"}}
35 | }
36 | end
37 |
38 | test "includes extended image_config fields" do
39 | config =
40 | Coordinator.__test_build_generation_config__(
41 | image_config: %{
42 | aspect_ratio: "1:1",
43 | image_size: "2K",
44 | output_mime_type: "image/jpeg",
45 | output_compression_quality: 80
46 | }
47 | )
48 |
49 | assert config["imageConfig"] == %{
50 | "aspectRatio" => "1:1",
51 | "imageSize" => "2K",
52 | "outputMimeType" => "image/jpeg",
53 | "outputCompressionQuality" => 80
54 | }
55 | end
56 | end
57 |
58 | describe "__test_struct_to_api_map__/1" do
59 | test "encodes response modalities and media_resolution on struct" do
60 | gc =
61 | %GenerationConfig{
62 | response_modalities: [:text],
63 | media_resolution: :media_resolution_high
64 | }
65 |
66 | result = Coordinator.__test_struct_to_api_map__(gc)
67 |
68 | assert (Map.get(result, "responseModalities") || Map.get(result, :responseModalities)) == [
69 | "TEXT"
70 | ]
71 |
72 | assert (Map.get(result, "mediaResolution") || Map.get(result, :mediaResolution)) ==
73 | "MEDIA_RESOLUTION_HIGH"
74 | end
75 |
76 | test "encodes speech_config on struct" do
77 | gc = %GenerationConfig{
78 | speech_config: %SpeechConfig{
79 | language_code: "en-US",
80 | voice_config: %VoiceConfig{
81 | prebuilt_voice_config: %PrebuiltVoiceConfig{voice_name: "Aoede"}
82 | }
83 | }
84 | }
85 |
86 | result = Coordinator.__test_struct_to_api_map__(gc)
87 |
88 | speech_config = Map.get(result, "speechConfig") || Map.get(result, :speechConfig)
89 |
90 | assert speech_config == %{
91 | "languageCode" => "en-US",
92 | "voiceConfig" => %{"prebuiltVoiceConfig" => %{"voiceName" => "Aoede"}}
93 | }
94 | end
95 |
96 | test "encodes extended image_config on struct" do
97 | gc = %GenerationConfig{
98 | image_config: %GenerationConfig.ImageConfig{
99 | aspect_ratio: "1:1",
100 | image_size: "4K",
101 | output_mime_type: "image/png",
102 | output_compression_quality: 90
103 | }
104 | }
105 |
106 | result = Coordinator.__test_struct_to_api_map__(gc)
107 |
108 | image_config = Map.get(result, "imageConfig") || Map.get(result, :imageConfig)
109 |
110 | assert image_config == %{
111 | "aspectRatio" => "1:1",
112 | "imageSize" => "4K",
113 | "outputMimeType" => "image/png",
114 | "outputCompressionQuality" => 90
115 | }
116 | end
117 | end
118 | end
119 |
--------------------------------------------------------------------------------