defmodule Gemini.APIs.ContextCache do @moduledoc """ Context caching API for improved performance with long context. Context caching allows you to cache large amounts of content (code, documents) for reuse across multiple requests, reducing latency and cost. ## Usage # Create a cached context {:ok, cache} = Gemini.APIs.ContextCache.create( [%Gemini.Types.Content{role: "user", parts: [%{text: large_content}]}], display_name: "My Codebase", model: "gemini-2.5-flash" ) # Use cached context in requests {:ok, response} = Gemini.generate("Analyze this code", cached_content: cache.name ) # Delete when done :ok = Gemini.APIs.ContextCache.delete(cache.name) ## API Endpoints - `POST /cachedContents` - Create cached content - `GET /cachedContents` - List cached contents - `GET /cachedContents/{name}` - Get specific cache - `PATCH /cachedContents/{name}` - Update cache TTL - `DELETE /cachedContents/{name}` - Delete cache """ alias Gemini.Client.HTTP alias Gemini.Types.CachedContentUsageMetadata alias Gemini.Types.Content alias Gemini.Types.MediaResolution alias Gemini.Types.Part alias Gemini.Types.ToolSerialization alias Gemini.Utils.ResourceNames require Logger @type cache_opts :: [ display_name: String.t(), model: String.t(), ttl: non_neg_integer(), expire_time: DateTime.t(), system_instruction: String.t() | Content.t(), tools: [Altar.ADM.FunctionDeclaration.t()], tool_config: Altar.ADM.ToolConfig.t(), kms_key_name: String.t(), auth: :gemini | :vertex_ai, project_id: String.t(), location: String.t() ] @type cached_content :: %{ name: String.t(), display_name: String.t() | nil, model: String.t(), create_time: String.t() | nil, update_time: String.t() | nil, expire_time: String.t() | nil, usage_metadata: CachedContentUsageMetadata.t() | nil } @doc """ Create a new cached content. ## Parameters - `contents`: List of Content structs to cache - `opts`: Options including: - `:display_name` - Human-readable name (required) - `:model` - Model to use (default: default model) - `:ttl` - Time to live in seconds (default: 3600) - `:expire_time` - Specific expiration DateTime ## Returns - `{:ok, cached_content}` - Created cache metadata - `{:error, reason}` - Failed to create cache ## Examples {:ok, cache} = ContextCache.create( [Content.text("Large document content...")], display_name: "My Document", model: "gemini-2.5-flash", ttl: 7200 ) """ @spec create([Content.t()] | [map()] | String.t(), cache_opts()) :: {:ok, cached_content()} | {:error, term()} def create(contents, opts \\ []) def create(contents, opts) when is_binary(contents) do create([Content.text(contents)], opts) end def create(contents, opts) when is_list(contents) do display_name = Keyword.get(opts, :display_name) || raise ArgumentError, "display_name is required for cached content" model = Keyword.get(opts, :model, Gemini.Config.default_model()) full_model_name = ResourceNames.normalize_cache_model_name(model, opts) validate_cache_model(full_model_name) # Build TTL specification ttl_spec = build_ttl_spec(opts) # Format contents for API formatted_contents = format_contents(contents) request_body = base_create_body(full_model_name, display_name, formatted_contents, ttl_spec, opts) path = ResourceNames.cached_contents_path(opts) case HTTP.post(path, request_body, opts) do {:ok, response} -> {:ok, normalize_cache_response(response)} {:error, reason} -> {:error, reason} end end @doc """ List all cached contents. ## Parameters - `opts`: Options including: - `:page_size` - Number of results per page - `:page_token` - Pagination token ## Returns - `{:ok, %{cached_contents: [cached_content()], next_page_token: String.t() | nil}}` - `{:error, reason}` """ @spec list(keyword()) :: {:ok, map()} | {:error, term()} def list(opts \\ []) do query_params = [] query_params = if opts[:page_size], do: [{:pageSize, opts[:page_size]} | query_params], else: query_params query_params = if opts[:page_token], do: [{:pageToken, opts[:page_token]} | query_params], else: query_params base_path = ResourceNames.cached_contents_path(opts) path = if query_params == [] do base_path else query_string = URI.encode_query(query_params) "#{base_path}?#{query_string}" end case HTTP.get(path, opts) do {:ok, response} -> cached_contents = response |> Map.get("cachedContents", []) |> Enum.map(&normalize_cache_response/1) result = %{ cached_contents: cached_contents, next_page_token: Map.get(response, "nextPageToken") } {:ok, result} {:error, reason} -> {:error, reason} end end @doc """ Get a specific cached content by name. ## Parameters - `name`: Cache name (format: "cachedContents/{id}") - `opts`: Request options ## Returns - `{:ok, cached_content}` - Cache metadata - `{:error, reason}` - Failed to get cache """ @spec get(String.t(), keyword()) :: {:ok, cached_content()} | {:error, term()} def get(name, opts \\ []) when is_binary(name) do normalized_name = ResourceNames.normalize_cached_content_name(name, opts) case HTTP.get(normalized_name, opts) do {:ok, response} -> {:ok, normalize_cache_response(response)} {:error, reason} -> {:error, reason} end end @doc """ Update cache TTL. ## Parameters - `name`: Cache name - `opts`: Options including: - `:ttl` - New TTL in seconds - `:expire_time` - New expiration DateTime ## Returns - `{:ok, cached_content}` - Updated cache metadata - `{:error, reason}` - Failed to update """ @spec update(String.t(), keyword()) :: {:ok, cached_content()} | {:error, term()} def update(name, opts \\ []) when is_binary(name) do ttl_spec = build_ttl_spec(opts) normalized_name = ResourceNames.normalize_cached_content_name(name, opts) if map_size(ttl_spec) == 0 do {:error, "Must specify either :ttl or :expire_time"} else # PATCH request case HTTP.patch(normalized_name, ttl_spec, opts) do {:ok, response} -> {:ok, normalize_cache_response(response)} {:error, reason} -> {:error, reason} end end end @doc """ Delete a cached content. ## Parameters - `name`: Cache name to delete - `opts`: Request options ## Returns - `:ok` - Successfully deleted - `{:error, reason}` - Failed to delete """ @spec delete(String.t(), keyword()) :: :ok | {:error, term()} def delete(name, opts \\ []) when is_binary(name) do normalized_name = ResourceNames.normalize_cached_content_name(name, opts) case HTTP.delete(normalized_name, opts) do {:ok, _response} -> :ok {:error, reason} -> {:error, reason} end end # Private helpers defp base_create_body(full_model_name, display_name, formatted_contents, ttl_spec, opts) do %{ model: full_model_name, displayName: display_name, contents: formatted_contents } |> Map.merge(ttl_spec) |> maybe_add_system_instruction(opts) |> maybe_add_tools(opts) |> maybe_add_tool_config(opts) |> maybe_add_kms_key(opts) end defp build_ttl_spec(opts) do cond do opts[:expire_time] -> expire_str = DateTime.to_iso8601(opts[:expire_time]) %{expireTime: expire_str} opts[:ttl] -> %{ttl: "#{opts[:ttl]}s"} true -> default_ttl = Keyword.get(opts, :default_ttl_seconds, default_ttl_seconds()) %{ttl: "#{default_ttl}s"} end end defp maybe_add_system_instruction(map, opts) do case Keyword.get(opts, :system_instruction) do nil -> map instruction when is_binary(instruction) -> Map.put(map, :systemInstruction, %{role: "user", parts: [%{text: instruction}]}) %Content{} = content -> Map.put(map, :systemInstruction, format_content(content)) %{} = content -> Map.put(map, :systemInstruction, format_content(content)) end end defp default_ttl_seconds do context_cache_config = Application.get_env(:gemini_ex, :context_cache, []) Keyword.get(context_cache_config, :default_ttl_seconds, 3_600) end defp maybe_add_tools(map, opts) do case Keyword.get(opts, :tools) do [_ | _] = tools -> Map.put(map, :tools, ToolSerialization.to_api_tool_list(tools)) _ -> map end end defp maybe_add_tool_config(map, opts) do case Keyword.get(opts, :tool_config) do %Altar.ADM.ToolConfig{} = tool_config -> Map.put(map, :toolConfig, ToolSerialization.to_api_tool_config(tool_config)) _ -> map end end defp maybe_add_kms_key(map, opts) do case Keyword.get(opts, :kms_key_name) do nil -> map kms_key -> auth_type = Keyword.get(opts, :auth) || auth_type_from_config() if auth_type == :vertex_ai do Map.put(map, :encryptionSpec, %{kmsKeyName: kms_key}) else raise ArgumentError, "kms_key_name is only supported with Vertex AI authentication" end end end defp format_contents(contents) do Enum.map(contents, &format_content/1) end defp format_content(%Content{role: role, parts: parts}) do %{role: role, parts: format_parts(parts)} end defp format_content(%{role: role, parts: parts}) do %{role: role, parts: format_parts(parts)} end defp format_content(%{parts: parts} = map) do role = Map.get(map, :role) || Map.get(map, "role") || "user" %{role: role, parts: format_parts(parts)} end defp format_content(%{text: text} = map) when is_binary(text) do role = Map.get(map, :role, "user") parts = Map.get(map, :parts, [%{text: text}]) %{role: role, parts: format_parts(parts)} end defp format_content(text) when is_binary(text) do %{role: "user", parts: [%{text: text}]} end defp format_parts(parts) when is_list(parts) do Enum.map(parts, &format_part/1) end defp format_part(%Part{text: text} = part) when is_binary(text) do %{text: text} |> maybe_put_thought_signature(part) |> maybe_put_media_resolution(part) end defp format_part(%{text: text} = part) when is_binary(text) do %{text: text} |> maybe_put_thought_signature(part) end defp format_part(%Part{inline_data: %{data: data, mime_type: mime}} = part) do %{inlineData: %{data: data, mimeType: mime}} |> maybe_put_media_resolution(part) end defp format_part(%{inline_data: %{data: data, mime_type: mime}} = part) do %{inlineData: %{data: data, mimeType: mime}} |> maybe_put_media_resolution(part) end defp format_part(%Part{function_call: %Altar.ADM.FunctionCall{name: name, args: args}} = part) do %{functionCall: %{name: name, args: args || %{}}} |> maybe_put_thought_signature(part) end defp format_part(%{function_call: %{name: name} = call} = part) when is_binary(name) do args = Map.get(call, :args, %{}) || %{} %{functionCall: %{name: name, args: args}} |> maybe_put_thought_signature(part) end defp format_part(%{function_call: %{"name" => name} = call} = part) when is_binary(name) do args = Map.get(call, "args", %{}) || %{} %{functionCall: %{name: name, args: args}} |> maybe_put_thought_signature(part) end defp format_part(%{file_data: %{file_uri: uri, mime_type: mime}}) when is_binary(uri) do %{fileData: %{fileUri: uri, mimeType: mime}} end defp format_part(%{file_data: %{file_uri: uri}}) when is_binary(uri) do %{fileData: %{fileUri: uri}} end defp format_part(%{file_uri: uri}) when is_binary(uri) do %{fileData: %{fileUri: uri}} end defp format_part(%{"fileData" => _} = part), do: part defp format_part(%{"functionResponse" => _} = part), do: part defp format_part(%{"functionCall" => _} = part), do: part defp format_part(%{parts: _} = part), do: part defp format_part(other), do: other defp maybe_put_thought_signature(map, %{thought_signature: sig}) when is_binary(sig) do Map.put(map, :thoughtSignature, sig) end defp maybe_put_thought_signature(map, %{"thoughtSignature" => sig}) when is_binary(sig) do Map.put(map, :thoughtSignature, sig) end defp maybe_put_thought_signature(map, _), do: map defp maybe_put_media_resolution(map, %{media_resolution: resolution}) do case media_resolution_to_api(resolution) do nil -> map api_value -> Map.put(map, :mediaResolution, api_value) end end defp maybe_put_media_resolution(map, _), do: map defp media_resolution_to_api(%Part.MediaResolution{level: level}), do: media_resolution_to_api(level) defp media_resolution_to_api(atom) when is_atom(atom), do: MediaResolution.to_api(atom) defp media_resolution_to_api(value) when is_binary(value) do value |> MediaResolution.from_api() |> MediaResolution.to_api() end defp media_resolution_to_api(_), do: nil defp normalize_cache_response(response) when is_map(response) do %{ name: Map.get(response, "name"), display_name: Map.get(response, "displayName"), model: Map.get(response, "model"), create_time: Map.get(response, "createTime"), update_time: Map.get(response, "updateTime"), expire_time: Map.get(response, "expireTime"), usage_metadata: normalize_usage_metadata(Map.get(response, "usageMetadata")) } end defp normalize_usage_metadata(nil), do: nil defp normalize_usage_metadata(metadata) when is_map(metadata) do %CachedContentUsageMetadata{ total_token_count: Map.get(metadata, "totalTokenCount"), cached_content_token_count: Map.get(metadata, "cachedContentTokenCount"), audio_duration_seconds: Map.get(metadata, "audioDurationSeconds"), image_count: Map.get(metadata, "imageCount"), text_count: Map.get(metadata, "textCount"), video_duration_seconds: Map.get(metadata, "videoDurationSeconds") } end @valid_cache_models [ "gemini-2.0-flash-001", "gemini-2.0-flash-lite-001", "gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-2.5-pro", "gemini-3-pro-preview", "gemini-3-flash-preview" ] defp validate_cache_model(model) do base_model = model |> String.split("/") |> List.last() unless Enum.any?(@valid_cache_models, &String.starts_with?(base_model, &1)) do Logger.warning( "Model #{model} may not support explicit caching. " <> "Use models with explicit version suffixes like 'gemini-2.5-flash'" ) end :ok end defp auth_type_from_config do case Gemini.Config.auth_config() do %{type: type} -> type _ -> :gemini end end end