Current section

Files

Jump to
langchain lib file_uploaders file_google.ex
Raw

lib/file_uploaders/file_google.ex

defmodule LangChain.FileUploader.FileGoogle do
@moduledoc """
Uploads files to Google Gemini's [File API](https://ai.google.dev/gemini-api/docs/files).
Google uses a two-step resumable upload protocol internally, but this is
abstracted away — callers simply call `upload/3`.
## Usage
{:ok, uploader} = LangChain.FileUploader.FileGoogle.new(%{api_key: "AI..."})
{:ok, result} = LangChain.FileUploader.upload(uploader, file_bytes, %{
filename: "document.pdf",
mime_type: "application/pdf"
})
result.file_id
#=> "files/abc-123"
result.file_uri
#=> "https://generativelanguage.googleapis.com/v1beta/files/abc-123"
Google identifies files by resource name (e.g. `"files/abc-123"`), which is
stored as `file_id` in the returned `FileResult`. The `file_uri` is also
available for use with `ContentPart.file_url!/2`.
Note: Files uploaded to Gemini expire after 48 hours.
## Client-side direct upload
Step 2 can be performed by a browser or mobile client instead of your server.
Call `request_upload_url/5` with the client's origin and forward the returned
URL to the client — the client then PUTs the file bytes to that URL directly.
{:ok, upload_url} =
FileGoogle.request_upload_url(uploader, "doc.pdf", "application/pdf", 12_345,
origin: "https://app.example.com"
)
The `:origin` option is required for browser uploads because Google uses it
to set `Access-Control-Allow-Origin` on the step 2 response.
"""
use Ecto.Schema
import Ecto.Changeset
alias __MODULE__
alias LangChain.Config
alias LangChain.LangChainError
alias LangChain.FileUploader
alias LangChain.FileUploader.FileResult
@behaviour FileUploader
@default_endpoint "https://generativelanguage.googleapis.com"
@upload_path "/upload/v1beta/files"
@files_path "/v1beta/files"
@receive_timeout 300_000
@primary_key false
embedded_schema do
field :endpoint, :string, default: @default_endpoint
field :api_key, :string, redact: true
field :receive_timeout, :integer, default: @receive_timeout
field :req_opts, :any, virtual: true, default: []
end
@type t :: %FileGoogle{}
@create_fields [:endpoint, :api_key, :receive_timeout, :req_opts]
@required_fields [:endpoint]
@doc """
Setup a Google file uploader configuration.
"""
@spec new(attrs :: map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
def new(%{} = attrs \\ %{}) do
%FileGoogle{}
|> cast(attrs, @create_fields)
|> common_validation()
|> apply_action(:insert)
end
@doc """
Setup a Google file uploader configuration and return it or raise an error if invalid.
"""
@spec new!(attrs :: map()) :: t() | no_return()
def new!(attrs \\ %{}) do
case new(attrs) do
{:ok, uploader} -> uploader
{:error, changeset} -> raise LangChainError, changeset
end
end
defp common_validation(changeset) do
changeset
|> validate_required(@required_fields)
|> validate_number(:receive_timeout, greater_than_or_equal_to: 0)
end
@impl FileUploader
def upload(%FileGoogle{} = uploader, file_bytes, %{} = file_meta) when is_binary(file_bytes) do
filename = Map.fetch!(file_meta, :filename)
mime_type = Map.fetch!(file_meta, :mime_type)
display_name = Map.get(file_meta, :display_name, filename)
byte_count = byte_size(file_bytes)
with {:ok, upload_url} <- request_upload_url(uploader, display_name, mime_type, byte_count),
{:ok, result} <- upload_file_bytes(uploader, upload_url, file_bytes, byte_count) do
{:ok, result}
end
end
@impl FileUploader
def get(%FileGoogle{} = uploader, %FileResult{file_id: file_id})
when is_binary(file_id) do
get(uploader, file_id)
end
@doc """
Retrieve file metadata by its resource name.
`file_name` must be the Google resource name in the form `"files/{id}"`,
e.g. `"files/abc-123"`. This is the `file_id` value stored in `FileResult`.
"""
def get(%FileGoogle{} = uploader, file_name) when is_binary(file_name) do
api_key = get_api_key(uploader)
url = "#{uploader.endpoint}/v1beta/#{file_name}?key=#{api_key}"
Req.new(url: url, receive_timeout: uploader.receive_timeout)
|> Req.merge(uploader.req_opts)
|> Req.get()
|> case do
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, parse_file_object(body)}
{:ok, %Req.Response{body: body}} ->
{:error, LangChainError.exception(message: "Get failed", original: body)}
{:error, err} ->
{:error,
LangChainError.exception(message: "Request error: #{inspect(err)}", original: err)}
end
end
@impl FileUploader
def delete(%FileGoogle{} = uploader, %FileResult{file_id: file_id})
when is_binary(file_id) do
delete(uploader, file_id)
end
@doc """
Delete a file by its resource name.
`file_name` must be the Google resource name in the form `"files/{id}"`,
e.g. `"files/abc-123"`. This is the `file_id` value stored in `FileResult`.
"""
def delete(%FileGoogle{} = uploader, file_name) when is_binary(file_name) do
api_key = get_api_key(uploader)
url = "#{uploader.endpoint}/v1beta/#{file_name}?key=#{api_key}"
Req.new(url: url, receive_timeout: uploader.receive_timeout)
|> Req.merge(uploader.req_opts)
|> Req.delete()
|> case do
{:ok, %Req.Response{status: status}} when status in [200, 204] ->
:ok
{:ok, %Req.Response{body: body}} ->
{:error, LangChainError.exception(message: "Delete failed", original: body)}
{:error, err} ->
{:error,
LangChainError.exception(message: "Request error: #{inspect(err)}", original: err)}
end
end
@impl FileUploader
def list(%FileGoogle{} = uploader) do
api_key = get_api_key(uploader)
url = "#{uploader.endpoint}#{@files_path}?key=#{api_key}"
Req.new(url: url, receive_timeout: uploader.receive_timeout)
|> Req.merge(uploader.req_opts)
|> Req.get()
|> case do
{:ok, %Req.Response{status: 200, body: %{"files" => files}}} when is_list(files) ->
{:ok, Enum.map(files, &parse_file_object/1)}
{:ok, %Req.Response{status: 200}} ->
{:ok, []}
{:ok, %Req.Response{body: body}} ->
{:error, LangChainError.exception(message: "List failed", original: body)}
{:error, err} ->
{:error,
LangChainError.exception(message: "Request error: #{inspect(err)}", original: err)}
end
end
@doc """
Request a presigned upload URL from Google's resumable upload endpoint (step 1 of 2).
Returns `{:ok, upload_url}` on success, or `{:error, LangChainError.t()}` on failure.
The returned URL can be passed to `upload_file_bytes/4` to complete the upload, or
forwarded directly to a client (e.g. a browser or mobile app) so it can upload
the file bytes itself without routing them through your server.
## Options
* `:origin` — When step 2 will be performed by a browser or mobile client,
pass the client's origin (scheme + host, e.g. `"https://app.example.com"`).
Google uses this origin to set `Access-Control-Allow-Origin` on the step 2
response, allowing the cross-origin upload to succeed. Omit when step 2 is
performed server-side.
"""
@spec request_upload_url(t(), String.t(), String.t(), non_neg_integer(), keyword()) ::
{:ok, String.t()} | {:error, LangChain.LangChainError.t()}
def request_upload_url(
%FileGoogle{} = uploader,
display_name,
mime_type,
byte_count,
opts \\ []
) do
api_key = get_api_key(uploader)
url = "#{uploader.endpoint}#{@upload_path}?key=#{api_key}"
headers = %{
"x-goog-upload-protocol" => "resumable",
"x-goog-upload-command" => "start",
"x-goog-upload-header-content-length" => to_string(byte_count),
"x-goog-upload-header-content-type" => mime_type
}
headers =
case Keyword.get(opts, :origin) do
nil -> headers
origin when is_binary(origin) -> Map.put(headers, "origin", origin)
end
Req.new(
url: url,
json: %{"file" => %{"display_name" => display_name}},
headers: headers,
receive_timeout: uploader.receive_timeout
)
|> Req.merge(uploader.req_opts)
|> Req.post()
|> case do
{:ok, %Req.Response{status: 200, headers: resp_headers}} ->
case Map.get(resp_headers, "x-goog-upload-url") do
[upload_url | _] ->
{:ok, upload_url}
_ ->
{:error,
LangChainError.exception(
message: "Missing x-goog-upload-url header in initiate response"
)}
end
{:ok, %Req.Response{body: body}} ->
{:error,
LangChainError.exception(message: "Failed to request upload URL", original: body)}
{:error, err} ->
{:error,
LangChainError.exception(message: "Request error: #{inspect(err)}", original: err)}
end
end
@doc """
Upload raw file bytes to a presigned upload URL (step 2 of 2).
`upload_url` is obtained from `request_upload_url/4`. Returns `{:ok, FileResult.t()}` on
success, or `{:error, LangChainError.t()}` on failure.
"""
@spec upload_file_bytes(t(), String.t(), binary(), non_neg_integer()) ::
{:ok, FileResult.t()} | {:error, LangChain.LangChainError.t()}
def upload_file_bytes(%FileGoogle{} = uploader, upload_url, file_bytes, byte_count) do
Req.new(
url: upload_url,
body: file_bytes,
headers: %{
"content-length" => to_string(byte_count),
"x-goog-upload-offset" => "0",
"x-goog-upload-command" => "upload, finalize"
},
receive_timeout: uploader.receive_timeout
)
|> Req.merge(uploader.req_opts)
|> Req.post()
|> case do
{:ok, %Req.Response{status: 200, body: %{"file" => file_data}}} ->
{:ok, parse_file_object(file_data)}
{:ok, %Req.Response{body: body}} ->
{:error, LangChainError.exception(message: "Failed to upload file bytes", original: body)}
{:error, %Req.TransportError{reason: :timeout} = err} ->
{:error,
LangChainError.exception(type: "timeout", message: "Request timed out", original: err)}
{:error, err} ->
{:error,
LangChainError.exception(message: "Request error: #{inspect(err)}", original: err)}
end
end
defp get_api_key(%FileGoogle{api_key: api_key}) do
api_key || Config.resolve(:google_ai_key, "")
end
defp parse_file_object(body) do
FileResult.new!(%{
file_id: body["name"],
file_uri: body["uri"],
filename: body["displayName"] || body["name"],
mime_type: body["mimeType"],
provider: :google,
raw: body
})
end
end