Current section
Files
Jump to
Current section
Files
src/starlet@gemini.erl
-module(starlet@gemini).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/starlet/gemini.gleam").
-export([with_thinking/2, thinking/1, list_models_with_base_url/2, list_models/1, encode_request/2, decode_error_response/1, decode_response/1, new_with_base_url/2, new/1]).
-export_type([thinking_budget/0, ext/0, model/0, part/0]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Google Gemini provider for starlet.\n"
"\n"
" Uses the [Gemini API](https://ai.google.dev/gemini-api/docs) via Google AI Studio.\n"
"\n"
" ## Usage\n"
"\n"
" ```gleam\n"
" import starlet\n"
" import starlet/gemini\n"
"\n"
" let client = gemini.new(api_key)\n"
"\n"
" starlet.chat(client, \"gemini-2.5-flash\")\n"
" |> starlet.user(\"Hello!\")\n"
" |> starlet.send()\n"
" ```\n"
"\n"
" ## Thinking Mode\n"
"\n"
" For Gemini 2.5+ models, configure thinking:\n"
"\n"
" ```gleam\n"
" starlet.chat(client, \"gemini-2.5-flash\")\n"
" |> gemini.with_thinking(gemini.ThinkingDynamic)\n"
" |> starlet.user(\"Solve this step by step...\")\n"
" |> starlet.send()\n"
" ```\n"
).
-type thinking_budget() :: thinking_off |
thinking_dynamic |
{thinking_fixed, integer()}.
-type ext() :: {ext,
gleam@option:option(thinking_budget()),
gleam@option:option(binary())}.
-type model() :: {model, binary(), binary()}.
-type part() :: {text_part, binary()} |
{function_call_part, starlet@tool:call()} |
{thought_part, binary()} |
skipped_part.
-file("src/starlet/gemini.gleam", 92).
?DOC(
" Enable thinking mode for Gemini 2.5+ models.\n"
" Validates that ThinkingFixed is within range 1-32768.\n"
).
-spec with_thinking(starlet:chat(QOJ, QOK, QOL, ext()), thinking_budget()) -> {ok,
starlet:chat(QOJ, QOK, QOL, ext())} |
{error, starlet:starlet_error()}.
with_thinking(Chat, Budget) ->
case Budget of
{thinking_fixed, Tokens} when Tokens < 1 ->
{error,
{provider,
<<"gemini"/utf8>>,
<<"thinking budget must be at least 1 token"/utf8>>,
<<""/utf8>>}};
{thinking_fixed, Tokens@1} when Tokens@1 > 32768 ->
{error,
{provider,
<<"gemini"/utf8>>,
<<"thinking budget must be at most 32768 tokens"/utf8>>,
<<""/utf8>>}};
_ ->
{ok,
{chat,
erlang:element(2, Chat),
erlang:element(3, Chat),
erlang:element(4, Chat),
erlang:element(5, Chat),
erlang:element(6, Chat),
erlang:element(7, Chat),
erlang:element(8, Chat),
begin
_record = erlang:element(9, Chat),
{ext, {some, Budget}, erlang:element(3, _record)}
end,
erlang:element(10, Chat),
erlang:element(11, Chat)}}
end.
-file("src/starlet/gemini.gleam", 114).
?DOC(" Get the thinking content from a Gemini turn (if present).\n").
-spec thinking(starlet:turn(any(), any(), ext())) -> gleam@option:option(binary()).
thinking(Turn) ->
erlang:element(3, erlang:element(4, Turn)).
-file("src/starlet/gemini.gleam", 168).
-spec decode_models(binary()) -> {ok, list(model())} |
{error, starlet:starlet_error()}.
decode_models(Body) ->
Model_decoder = begin
gleam@dynamic@decode:field(
<<"name"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Name) ->
gleam@dynamic@decode:field(
<<"displayName"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Display_name) ->
Id = case gleam@string:split(Name, <<"/"/utf8>>) of
[_, Model_id] ->
Model_id;
_ ->
Name
end,
gleam@dynamic@decode:success({model, Id, Display_name})
end
)
end
)
end,
Decoder = begin
gleam@dynamic@decode:field(
<<"models"/utf8>>,
gleam@dynamic@decode:list(Model_decoder),
fun(Models) -> gleam@dynamic@decode:success(Models) end
)
end,
case gleam@json:parse(Body, Decoder) of
{ok, Models@1} ->
{ok, Models@1};
{error, Err} ->
{error,
{decode,
<<"Failed to decode Gemini models: "/utf8,
(gleam@string:inspect(Err))/binary>>}}
end.
-file("src/starlet/gemini.gleam", 127).
?DOC(" Lists available Gemini models with a custom base URL.\n").
-spec list_models_with_base_url(binary(), binary()) -> {ok, list(model())} |
{error, starlet:starlet_error()}.
list_models_with_base_url(Api_key, Base_url) ->
case gleam_stdlib:uri_parse(Base_url) of
{ok, Base_uri} ->
Scheme = case gleam@option:unwrap(
erlang:element(2, Base_uri),
<<"https"/utf8>>
) of
<<"http"/utf8>> ->
http;
_ ->
https
end,
Host = gleam@option:unwrap(
erlang:element(4, Base_uri),
<<"generativelanguage.googleapis.com"/utf8>>
),
Base_path = erlang:element(6, Base_uri),
Http_request = begin
_pipe = gleam@http@request:new(),
_pipe@1 = gleam@http@request:set_method(_pipe, get),
_pipe@2 = gleam@http@request:set_scheme(_pipe@1, Scheme),
_pipe@3 = gleam@http@request:set_host(_pipe@2, Host),
_pipe@4 = starlet@internal@http:set_optional_port(
_pipe@3,
erlang:element(5, Base_uri)
),
_pipe@5 = gleam@http@request:set_path(
_pipe@4,
<<Base_path/binary, "/v1beta/models"/utf8>>
),
gleam@http@request:set_header(
_pipe@5,
<<"x-goog-api-key"/utf8>>,
Api_key
)
end,
case gleam@httpc:send(Http_request) of
{ok, Response} ->
case erlang:element(2, Response) of
200 ->
decode_models(erlang:element(4, Response));
429 ->
Retry_after = starlet@internal@http:parse_retry_after(
erlang:element(3, Response)
),
{error, {rate_limited, Retry_after}};
Status ->
{error, {http, Status, erlang:element(4, Response)}}
end;
{error, Err} ->
{error, {transport, gleam@string:inspect(Err)}}
end;
{error, _} ->
{error, {transport, <<"Invalid base URL: "/utf8, Base_url/binary>>}}
end.
-file("src/starlet/gemini.gleam", 119).
?DOC(" Lists available Gemini models.\n").
-spec list_models(binary()) -> {ok, list(model())} |
{error, starlet:starlet_error()}.
list_models(Api_key) ->
list_models_with_base_url(
Api_key,
<<"https://generativelanguage.googleapis.com"/utf8>>
).
-file("src/starlet/gemini.gleam", 241).
-spec build_contents(list(starlet:message())) -> list(gleam@json:json()).
build_contents(Messages) ->
gleam@list:map(Messages, fun(Msg) -> case Msg of
{user_message, Content} ->
gleam@json:object(
[{<<"role"/utf8>>, gleam@json:string(<<"user"/utf8>>)},
{<<"parts"/utf8>>,
gleam@json:array(
[gleam@json:object(
[{<<"text"/utf8>>,
gleam@json:string(Content)}]
)],
fun(P) -> P end
)}]
);
{assistant_message, Content@1, Tool_calls} ->
case Tool_calls of
[] ->
gleam@json:object(
[{<<"role"/utf8>>,
gleam@json:string(<<"model"/utf8>>)},
{<<"parts"/utf8>>,
gleam@json:array(
[gleam@json:object(
[{<<"text"/utf8>>,
gleam@json:string(
Content@1
)}]
)],
fun(P@1) -> P@1 end
)}]
);
_ ->
Text_parts = case Content@1 of
<<""/utf8>> ->
[];
_ ->
[gleam@json:object(
[{<<"text"/utf8>>,
gleam@json:string(Content@1)}]
)]
end,
Call_parts = gleam@list:map(
Tool_calls,
fun(Call) ->
gleam@json:object(
[{<<"functionCall"/utf8>>,
gleam@json:object(
[{<<"name"/utf8>>,
gleam@json:string(
erlang:element(
3,
Call
)
)},
{<<"args"/utf8>>,
starlet@tool:dynamic_to_json(
erlang:element(
4,
Call
)
)}]
)}]
)
end
),
gleam@json:object(
[{<<"role"/utf8>>,
gleam@json:string(<<"model"/utf8>>)},
{<<"parts"/utf8>>,
gleam@json:array(
lists:append(Text_parts, Call_parts),
fun(P@2) -> P@2 end
)}]
)
end;
{tool_result_message, _, Name, Content@2} ->
Response = case gleam@json:parse(
Content@2,
{decoder, fun gleam@dynamic@decode:decode_dynamic/1}
) of
{ok, Parsed} ->
starlet@tool:dynamic_to_json(Parsed);
{error, _} ->
gleam@json:object(
[{<<"result"/utf8>>,
gleam@json:string(Content@2)}]
)
end,
gleam@json:object(
[{<<"role"/utf8>>, gleam@json:string(<<"user"/utf8>>)},
{<<"parts"/utf8>>,
gleam@json:array(
[gleam@json:object(
[{<<"functionResponse"/utf8>>,
gleam@json:object(
[{<<"name"/utf8>>,
gleam@json:string(
Name
)},
{<<"response"/utf8>>,
Response}]
)}]
)],
fun(P@3) -> P@3 end
)}]
)
end end).
-file("src/starlet/gemini.gleam", 323).
-spec build_function_declarations(list(starlet@tool:definition())) -> gleam@json:json().
build_function_declarations(Tools) ->
gleam@json:object(
[{<<"functionDeclarations"/utf8>>,
gleam@json:array(Tools, fun(T) -> case T of
{function, Name, Description, Parameters} ->
gleam@json:object(
[{<<"name"/utf8>>, gleam@json:string(Name)},
{<<"description"/utf8>>,
gleam@json:string(Description)},
{<<"parameters"/utf8>>, Parameters}]
)
end end)}]
).
-file("src/starlet/gemini.gleam", 341).
-spec build_generation_config(starlet:request(), ext()) -> gleam@option:option(gleam@json:json()).
build_generation_config(Req, Ext) ->
Config = [],
Config@1 = case erlang:element(6, Req) of
{some, T} ->
[{<<"temperature"/utf8>>, gleam@json:float(T)} | Config];
none ->
Config
end,
Config@2 = case erlang:element(7, Req) of
{some, N} ->
[{<<"maxOutputTokens"/utf8>>, gleam@json:int(N)} | Config@1];
none ->
Config@1
end,
Config@3 = case erlang:element(8, Req) of
{some, Schema} ->
[{<<"responseMimeType"/utf8>>,
gleam@json:string(<<"application/json"/utf8>>)},
{<<"responseSchema"/utf8>>, Schema} |
Config@2];
none ->
Config@2
end,
Config@4 = case erlang:element(2, Ext) of
{some, thinking_off} ->
[{<<"thinkingConfig"/utf8>>,
gleam@json:object(
[{<<"thinkingBudget"/utf8>>, gleam@json:int(0)}]
)} |
Config@3];
{some, thinking_dynamic} ->
[{<<"thinkingConfig"/utf8>>,
gleam@json:object(
[{<<"thinkingBudget"/utf8>>, gleam@json:int(-1)},
{<<"includeThoughts"/utf8>>, gleam@json:bool(true)}]
)} |
Config@3];
{some, {thinking_fixed, Tokens}} ->
[{<<"thinkingConfig"/utf8>>,
gleam@json:object(
[{<<"thinkingBudget"/utf8>>, gleam@json:int(Tokens)},
{<<"includeThoughts"/utf8>>, gleam@json:bool(true)}]
)} |
Config@3];
none ->
Config@3
end,
case Config@4 of
[] ->
none;
_ ->
{some, gleam@json:object(Config@4)}
end.
-file("src/starlet/gemini.gleam", 195).
?DOC(false).
-spec encode_request(starlet:request(), ext()) -> gleam@json:json().
encode_request(Req, Ext) ->
Contents = build_contents(erlang:element(4, Req)),
Base = [{<<"contents"/utf8>>, gleam@json:array(Contents, fun(C) -> C end)}],
Base@1 = case erlang:element(3, Req) of
{some, Prompt} ->
lists:append(
Base,
[{<<"systemInstruction"/utf8>>,
gleam@json:object(
[{<<"parts"/utf8>>,
gleam@json:array(
[gleam@json:object(
[{<<"text"/utf8>>,
gleam@json:string(
Prompt
)}]
)],
fun(P) -> P end
)}]
)}]
);
none ->
Base
end,
Base@2 = case erlang:element(5, Req) of
[] ->
Base@1;
Tools ->
lists:append(
Base@1,
[{<<"tools"/utf8>>,
gleam@json:array(
[build_function_declarations(Tools)],
fun(T) -> T end
)}]
)
end,
Gen_config = build_generation_config(Req, Ext),
Base@3 = case Gen_config of
{some, Config} ->
lists:append(Base@2, [{<<"generationConfig"/utf8>>, Config}]);
none ->
Base@2
end,
gleam@json:object(Base@3).
-file("src/starlet/gemini.gleam", 469).
?DOC(false).
-spec decode_error_response(binary()) -> {ok, binary()} | {error, nil}.
decode_error_response(Body) ->
Decoder = begin
gleam@dynamic@decode:field(
<<"error"/utf8>>,
begin
gleam@dynamic@decode:field(
<<"message"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Message) -> gleam@dynamic@decode:success(Message) end
)
end,
fun(Error) -> gleam@dynamic@decode:success(Error) end
)
end,
_pipe = gleam@json:parse(Body, Decoder),
gleam@result:replace_error(_pipe, nil).
-file("src/starlet/gemini.gleam", 530).
-spec decode_text_part() -> gleam@dynamic@decode:decoder(part()).
decode_text_part() ->
gleam@dynamic@decode:field(
<<"text"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Text) -> gleam@dynamic@decode:success({text_part, Text}) end
).
-file("src/starlet/gemini.gleam", 535).
-spec decode_function_call_part() -> gleam@dynamic@decode:decoder(part()).
decode_function_call_part() ->
gleam@dynamic@decode:field(
<<"functionCall"/utf8>>,
begin
gleam@dynamic@decode:field(
<<"name"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Name) ->
gleam@dynamic@decode:field(
<<"args"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_dynamic/1},
fun(Arguments) ->
gleam@dynamic@decode:success({Name, Arguments})
end
)
end
)
end,
fun(Call) ->
{Name@1, Arguments@1} = Call,
gleam@dynamic@decode:success(
{function_call_part, {call, <<""/utf8>>, Name@1, Arguments@1}}
)
end
).
-file("src/starlet/gemini.gleam", 545).
-spec decode_thought_part() -> gleam@dynamic@decode:decoder(part()).
decode_thought_part() ->
gleam@dynamic@decode:field(
<<"thought"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_bool/1},
fun(Is_thought) -> case Is_thought of
true ->
gleam@dynamic@decode:field(
<<"text"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Text) ->
gleam@dynamic@decode:success({thought_part, Text})
end
);
false ->
gleam@dynamic@decode:failure(
{thought_part, <<""/utf8>>},
<<"thought is false"/utf8>>
)
end end
).
-file("src/starlet/gemini.gleam", 556).
-spec decode_skipped_part() -> gleam@dynamic@decode:decoder(part()).
decode_skipped_part() ->
gleam@dynamic@decode:success(skipped_part).
-file("src/starlet/gemini.gleam", 560).
-spec extract_text(list(part())) -> binary().
extract_text(Parts) ->
_pipe = gleam@list:filter_map(Parts, fun(Part) -> case Part of
{text_part, Text} ->
{ok, Text};
_ ->
{error, nil}
end end),
gleam@string:join(_pipe, <<""/utf8>>).
-file("src/starlet/gemini.gleam", 570).
-spec extract_tool_calls(list(part())) -> list(starlet@tool:call()).
extract_tool_calls(Parts) ->
_pipe = gleam@list:index_fold(
Parts,
[],
fun(Acc, Part, Index) -> case Part of
{function_call_part, Call} ->
Id = <<"gemini-"/utf8,
(erlang:integer_to_binary(Index))/binary>>,
[{call,
Id,
erlang:element(3, Call),
erlang:element(4, Call)} |
Acc];
_ ->
Acc
end end
),
lists:reverse(_pipe).
-file("src/starlet/gemini.gleam", 583).
-spec extract_thinking(list(part())) -> gleam@option:option(binary()).
extract_thinking(Parts) ->
Thinking_texts = gleam@list:filter_map(Parts, fun(Part) -> case Part of
{thought_part, Text} ->
{ok, Text};
_ ->
{error, nil}
end end),
case Thinking_texts of
[] ->
none;
Texts ->
{some, gleam@string:join(Texts, <<"\n"/utf8>>)}
end.
-file("src/starlet/gemini.gleam", 491).
?DOC(false).
-spec decode_response(binary()) -> {ok,
{starlet:response(), gleam@option:option(binary())}} |
{error, starlet:starlet_error()}.
decode_response(Body) ->
Part_decoder = gleam@dynamic@decode:one_of(
decode_thought_part(),
[decode_text_part(), decode_function_call_part(), decode_skipped_part()]
),
Decoder = begin
gleam@dynamic@decode:field(
<<"candidates"/utf8>>,
gleam@dynamic@decode:list(
begin
gleam@dynamic@decode:field(
<<"content"/utf8>>,
begin
gleam@dynamic@decode:field(
<<"parts"/utf8>>,
gleam@dynamic@decode:list(Part_decoder),
fun(Inner_parts) ->
gleam@dynamic@decode:success(Inner_parts)
end
)
end,
fun(Parts) -> gleam@dynamic@decode:success(Parts) end
)
end
),
fun(Candidates) -> gleam@dynamic@decode:success(Candidates) end
)
end,
case gleam@json:parse(Body, Decoder) of
{ok, [Parts@1 | _]} ->
Text = extract_text(Parts@1),
Tool_calls = extract_tool_calls(Parts@1),
Thinking = extract_thinking(Parts@1),
{ok, {{response, Text, Tool_calls}, Thinking}};
{ok, []} ->
{error,
{decode, <<"Gemini response contained no candidates"/utf8>>}};
{error, Err} ->
{error,
{decode,
<<"Failed to decode Gemini response: "/utf8,
(gleam@string:inspect(Err))/binary>>}}
end.
-file("src/starlet/gemini.gleam", 397).
-spec send_request(binary(), binary(), starlet:request(), ext()) -> {ok,
{starlet:response(), ext()}} |
{error, starlet:starlet_error()}.
send_request(Api_key, Base_url, Req, Ext) ->
Body = gleam@json:to_string(encode_request(Req, Ext)),
case gleam_stdlib:uri_parse(Base_url) of
{ok, Base_uri} ->
Scheme = case gleam@option:unwrap(
erlang:element(2, Base_uri),
<<"https"/utf8>>
) of
<<"http"/utf8>> ->
http;
_ ->
https
end,
Host = gleam@option:unwrap(
erlang:element(4, Base_uri),
<<"generativelanguage.googleapis.com"/utf8>>
),
Base_path = erlang:element(6, Base_uri),
Path = <<<<<<Base_path/binary, "/v1beta/models/"/utf8>>/binary,
(erlang:element(2, Req))/binary>>/binary,
":generateContent"/utf8>>,
Http_request = begin
_pipe = gleam@http@request:new(),
_pipe@1 = gleam@http@request:set_method(_pipe, post),
_pipe@2 = gleam@http@request:set_scheme(_pipe@1, Scheme),
_pipe@3 = gleam@http@request:set_host(_pipe@2, Host),
_pipe@4 = starlet@internal@http:set_optional_port(
_pipe@3,
erlang:element(5, Base_uri)
),
_pipe@5 = gleam@http@request:set_path(_pipe@4, Path),
_pipe@6 = gleam@http@request:set_header(
_pipe@5,
<<"content-type"/utf8>>,
<<"application/json"/utf8>>
),
_pipe@7 = gleam@http@request:set_header(
_pipe@6,
<<"x-goog-api-key"/utf8>>,
Api_key
),
gleam@http@request:set_body(_pipe@7, Body)
end,
Config = begin
_pipe@8 = gleam@httpc:configure(),
gleam@httpc:timeout(_pipe@8, erlang:element(9, Req))
end,
case gleam@httpc:dispatch(Config, Http_request) of
{ok, Response} ->
case erlang:element(2, Response) of
200 ->
case decode_response(erlang:element(4, Response)) of
{ok, {Resp, Thinking_content}} ->
New_ext = {ext,
erlang:element(2, Ext),
Thinking_content},
{ok, {Resp, New_ext}};
{error, E} ->
{error, E}
end;
429 ->
Retry_after = starlet@internal@http:parse_retry_after(
erlang:element(3, Response)
),
{error, {rate_limited, Retry_after}};
Status ->
case decode_error_response(
erlang:element(4, Response)
) of
{ok, Msg} ->
{error,
{provider,
<<"gemini"/utf8>>,
Msg,
erlang:element(4, Response)}};
{error, _} ->
{error,
{http,
Status,
erlang:element(4, Response)}}
end
end;
{error, Err} ->
{error, {transport, gleam@string:inspect(Err)}}
end;
{error, _} ->
{error, {transport, <<"Invalid base URL: "/utf8, Base_url/binary>>}}
end.
-file("src/starlet/gemini.gleam", 81).
?DOC(" Creates a new Gemini client with a custom base URL.\n").
-spec new_with_base_url(binary(), binary()) -> starlet:client(ext()).
new_with_base_url(Api_key, Base_url) ->
Config = {provider_config,
<<"gemini"/utf8>>,
Base_url,
fun(Req, Ext) -> send_request(Api_key, Base_url, Req, Ext) end},
Default_ext = {ext, none, none},
starlet:from_provider(Config, Default_ext).
-file("src/starlet/gemini.gleam", 76).
?DOC(
" Creates a new Gemini client with the given API key.\n"
" Uses the default base URL: https://generativelanguage.googleapis.com\n"
).
-spec new(binary()) -> starlet:client(ext()).
new(Api_key) ->
new_with_base_url(
Api_key,
<<"https://generativelanguage.googleapis.com"/utf8>>
).