Current section
Files
Jump to
Current section
Files
src/starlet@anthropic.erl
-module(starlet@anthropic).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/starlet/anthropic.gleam").
-export([with_thinking/2, thinking/1, decode_error_response/1, decode_response/1, encode_request/2, new_with_base_url/2, new/1]).
-export_type([ext/0, content_block/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(
" Anthropic provider for starlet.\n"
"\n"
" Uses the [Anthropic Messages API](https://docs.anthropic.com/en/api/messages)\n"
" for chat completions with Claude models.\n"
"\n"
" ## Usage\n"
"\n"
" ```gleam\n"
" import starlet\n"
" import starlet/anthropic\n"
"\n"
" let client = anthropic.new(api_key)\n"
"\n"
" starlet.chat(client, \"claude-haiku-4-5-20251001\")\n"
" |> starlet.user(\"Hello!\")\n"
" |> starlet.send()\n"
" ```\n"
"\n"
" ## Extended Thinking\n"
"\n"
" For models that support extended thinking, configure a thinking budget:\n"
"\n"
" ```gleam\n"
" starlet.chat(client, \"claude-haiku-4-5-20251001\")\n"
" |> anthropic.with_thinking(16384)\n"
" |> starlet.max_tokens(32000)\n"
" |> starlet.user(\"Analyze this complex problem...\")\n"
" |> starlet.send()\n"
" ```\n"
"\n"
" ## Note on max_tokens\n"
"\n"
" Anthropic requires `max_tokens` in every request. If not explicitly set\n"
" via `starlet.max_tokens()`, a default of 4096 is used.\n"
).
-type ext() :: {ext,
gleam@option:option(integer()),
gleam@option:option(binary())}.
-type content_block() :: {text_block, binary()} |
{tool_use_block, starlet@tool:call()} |
{thinking_block, binary()} |
skipped_block.
-file("src/starlet/anthropic.gleam", 96).
?DOC(
" Enable extended thinking with a token budget.\n"
" Budget must be at least 1024 tokens. The upper bound (less than max_tokens)\n"
" is enforced by the API at request time.\n"
).
-spec with_thinking(starlet:chat(QBX, QBY, QBZ, ext()), integer()) -> {ok,
starlet:chat(QBX, QBY, QBZ, ext())} |
{error, starlet:starlet_error()}.
with_thinking(Chat, Budget) ->
case Budget >= 1024 of
false ->
{error,
{provider,
<<"anthropic"/utf8>>,
<<"thinking budget must be at least 1024 tokens"/utf8>>,
<<""/utf8>>}};
true ->
{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/anthropic.gleam", 114).
?DOC(" Get the thinking content from an Anthropic 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/anthropic.gleam", 185).
-spec set_beta_headers(
gleam@http@request:request(binary()),
gleam@option:option(gleam@json:json()),
gleam@option:option(integer())
) -> gleam@http@request:request(binary()).
set_beta_headers(Req, Json_schema, Thinking_budget) ->
Betas = [],
Betas@1 = case Json_schema of
{some, _} ->
[<<"structured-outputs-2025-11-13"/utf8>> | Betas];
none ->
Betas
end,
Betas@2 = case Thinking_budget of
{some, _} ->
[<<"interleaved-thinking-2025-05-14"/utf8>> | Betas@1];
none ->
Betas@1
end,
case Betas@2 of
[] ->
Req;
_ ->
gleam@http@request:set_header(
Req,
<<"anthropic-beta"/utf8>>,
gleam@string:join(Betas@2, <<","/utf8>>)
)
end.
-file("src/starlet/anthropic.gleam", 211).
?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/anthropic.gleam", 282).
-spec encode_tools(list(starlet@tool:definition())) -> gleam@json:json().
encode_tools(Tools) ->
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)},
{<<"input_schema"/utf8>>, Parameters}]
)
end end).
-file("src/starlet/anthropic.gleam", 379).
-spec collect_tool_results_acc(
list(starlet:message()),
list({binary(), binary(), binary()})
) -> {list({binary(), binary(), binary()}), list(starlet:message())}.
collect_tool_results_acc(Messages, Acc) ->
case Messages of
[{tool_result_message, Id, Name, Content} | Rest] ->
collect_tool_results_acc(Rest, [{Id, Name, Content} | Acc]);
_ ->
{lists:reverse(Acc), Messages}
end.
-file("src/starlet/anthropic.gleam", 373).
-spec collect_tool_results(list(starlet:message())) -> {list({binary(),
binary(),
binary()}),
list(starlet:message())}.
collect_tool_results(Messages) ->
collect_tool_results_acc(Messages, []).
-file("src/starlet/anthropic.gleam", 300).
-spec encode_messages_acc(list(starlet:message()), list(gleam@json:json())) -> list(gleam@json:json()).
encode_messages_acc(Messages, Acc) ->
case Messages of
[] ->
Acc;
[Msg | Rest] ->
case Msg of
{user_message, Content} ->
Encoded = gleam@json:object(
[{<<"role"/utf8>>, gleam@json:string(<<"user"/utf8>>)},
{<<"content"/utf8>>, gleam@json:string(Content)}]
),
encode_messages_acc(Rest, [Encoded | Acc]);
{assistant_message, Content@1, Tool_calls} ->
Encoded@1 = case Tool_calls of
[] ->
gleam@json:object(
[{<<"role"/utf8>>,
gleam@json:string(<<"assistant"/utf8>>)},
{<<"content"/utf8>>,
gleam@json:string(Content@1)}]
);
_ ->
Text_blocks = case Content@1 of
<<""/utf8>> ->
[];
_ ->
[gleam@json:object(
[{<<"type"/utf8>>,
gleam@json:string(
<<"text"/utf8>>
)},
{<<"text"/utf8>>,
gleam@json:string(Content@1)}]
)]
end,
Tool_blocks = gleam@list:map(
Tool_calls,
fun(Call) ->
gleam@json:object(
[{<<"type"/utf8>>,
gleam@json:string(
<<"tool_use"/utf8>>
)},
{<<"id"/utf8>>,
gleam@json:string(
erlang:element(2, Call)
)},
{<<"name"/utf8>>,
gleam@json:string(
erlang:element(3, Call)
)},
{<<"input"/utf8>>,
starlet@tool:dynamic_to_json(
erlang:element(4, Call)
)}]
)
end
),
gleam@json:object(
[{<<"role"/utf8>>,
gleam@json:string(<<"assistant"/utf8>>)},
{<<"content"/utf8>>,
gleam@json:array(
lists:append(
Text_blocks,
Tool_blocks
),
fun(B) -> B end
)}]
)
end,
encode_messages_acc(Rest, [Encoded@1 | Acc]);
{tool_result_message, _, _, _} ->
{Results, Remaining} = collect_tool_results(Messages),
Content_blocks = gleam@list:map(
Results,
fun(R) ->
{Id, _, C} = R,
gleam@json:object(
[{<<"type"/utf8>>,
gleam@json:string(
<<"tool_result"/utf8>>
)},
{<<"tool_use_id"/utf8>>,
gleam@json:string(Id)},
{<<"content"/utf8>>, gleam@json:string(C)}]
)
end
),
Encoded@2 = gleam@json:object(
[{<<"role"/utf8>>, gleam@json:string(<<"user"/utf8>>)},
{<<"content"/utf8>>,
gleam@json:array(
Content_blocks,
fun(B@1) -> B@1 end
)}]
),
encode_messages_acc(Remaining, [Encoded@2 | Acc])
end
end.
-file("src/starlet/anthropic.gleam", 295).
-spec encode_messages(list(starlet:message())) -> list(gleam@json:json()).
encode_messages(Messages) ->
_pipe = encode_messages_acc(Messages, []),
lists:reverse(_pipe).
-file("src/starlet/anthropic.gleam", 429).
-spec decode_text_block() -> gleam@dynamic@decode:decoder(content_block()).
decode_text_block() ->
gleam@dynamic@decode:field(
<<"type"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Type_) -> case Type_ of
<<"text"/utf8>> ->
gleam@dynamic@decode:field(
<<"text"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Text) ->
gleam@dynamic@decode:success({text_block, Text})
end
);
_ ->
gleam@dynamic@decode:failure(
{text_block, <<""/utf8>>},
<<"text"/utf8>>
)
end end
).
-file("src/starlet/anthropic.gleam", 440).
-spec decode_tool_use_block() -> gleam@dynamic@decode:decoder(content_block()).
decode_tool_use_block() ->
gleam@dynamic@decode:field(
<<"type"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Type_) -> case Type_ of
<<"tool_use"/utf8>> ->
gleam@dynamic@decode:field(
<<"id"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Id) ->
gleam@dynamic@decode:field(
<<"name"/utf8>>,
{decoder,
fun gleam@dynamic@decode:decode_string/1},
fun(Name) ->
gleam@dynamic@decode:field(
<<"input"/utf8>>,
{decoder,
fun gleam@dynamic@decode:decode_dynamic/1},
fun(Arguments) ->
gleam@dynamic@decode:success(
{tool_use_block,
{call, Id, Name, Arguments}}
)
end
)
end
)
end
);
_ ->
gleam@dynamic@decode:failure(
{tool_use_block,
{call,
<<""/utf8>>,
<<""/utf8>>,
gleam@dynamic:nil()}},
<<"tool_use"/utf8>>
)
end end
).
-file("src/starlet/anthropic.gleam", 454).
-spec decode_thinking_block() -> gleam@dynamic@decode:decoder(content_block()).
decode_thinking_block() ->
gleam@dynamic@decode:field(
<<"type"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Type_) -> case Type_ of
<<"thinking"/utf8>> ->
gleam@dynamic@decode:field(
<<"thinking"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(Text) ->
gleam@dynamic@decode:success({thinking_block, Text})
end
);
_ ->
gleam@dynamic@decode:failure(
{thinking_block, <<""/utf8>>},
<<"thinking"/utf8>>
)
end end
).
-file("src/starlet/anthropic.gleam", 465).
-spec decode_skipped_block() -> gleam@dynamic@decode:decoder(content_block()).
decode_skipped_block() ->
gleam@dynamic@decode:field(
<<"type"/utf8>>,
{decoder, fun gleam@dynamic@decode:decode_string/1},
fun(_) -> gleam@dynamic@decode:success(skipped_block) end
).
-file("src/starlet/anthropic.gleam", 470).
-spec extract_text(list(content_block())) -> binary().
extract_text(Blocks) ->
_pipe = gleam@list:filter_map(Blocks, fun(Block) -> case Block of
{text_block, Text} ->
{ok, Text};
_ ->
{error, nil}
end end),
gleam@string:join(_pipe, <<""/utf8>>).
-file("src/starlet/anthropic.gleam", 480).
-spec extract_tool_calls(list(content_block())) -> list(starlet@tool:call()).
extract_tool_calls(Blocks) ->
gleam@list:filter_map(Blocks, fun(Block) -> case Block of
{tool_use_block, Call} ->
{ok, Call};
_ ->
{error, nil}
end end).
-file("src/starlet/anthropic.gleam", 489).
-spec extract_thinking(list(content_block())) -> gleam@option:option(binary()).
extract_thinking(Blocks) ->
Thinking_texts = gleam@list:filter_map(Blocks, fun(Block) -> case Block of
{thinking_block, Text} ->
{ok, Text};
_ ->
{error, nil}
end end),
case Thinking_texts of
[] ->
none;
Texts ->
{some, gleam@string:join(Texts, <<"\n"/utf8>>)}
end.
-file("src/starlet/anthropic.gleam", 400).
?DOC(false).
-spec decode_response(binary()) -> {ok,
{starlet:response(), gleam@option:option(binary())}} |
{error, starlet:starlet_error()}.
decode_response(Body) ->
Content_block_decoder = gleam@dynamic@decode:one_of(
decode_text_block(),
[decode_tool_use_block(),
decode_thinking_block(),
decode_skipped_block()]
),
Decoder = begin
gleam@dynamic@decode:field(
<<"content"/utf8>>,
gleam@dynamic@decode:list(Content_block_decoder),
fun(Content) -> gleam@dynamic@decode:success(Content) end
)
end,
case gleam@json:parse(Body, Decoder) of
{ok, Content_blocks} ->
Text = extract_text(Content_blocks),
Tool_calls = extract_tool_calls(Content_blocks),
Thinking = extract_thinking(Content_blocks),
{ok, {{response, Text, Tool_calls}, Thinking}};
{error, Err} ->
{error,
{decode,
<<"Failed to decode Anthropic response: "/utf8,
(gleam@string:inspect(Err))/binary>>}}
end.
-file("src/starlet/anthropic.gleam", 225).
?DOC(false).
-spec encode_request(starlet:request(), ext()) -> gleam@json:json().
encode_request(Req, Ext) ->
Max_tokens = gleam@option:unwrap(erlang:element(7, Req), 4096),
Messages = encode_messages(erlang:element(4, Req)),
Base = [{<<"model"/utf8>>, gleam@json:string(erlang:element(2, Req))},
{<<"max_tokens"/utf8>>, gleam@json:int(Max_tokens)},
{<<"messages"/utf8>>, gleam@json:array(Messages, fun(M) -> M end)}],
Base@1 = case erlang:element(3, Req) of
{some, Prompt} ->
[{<<"system"/utf8>>, gleam@json:string(Prompt)} | Base];
none ->
Base
end,
Base@2 = case erlang:element(6, Req) of
{some, T} ->
lists:append(
Base@1,
[{<<"temperature"/utf8>>, gleam@json:float(T)}]
);
none ->
Base@1
end,
Base@3 = case erlang:element(5, Req) of
[] ->
Base@2;
_ ->
lists:append(
Base@2,
[{<<"tools"/utf8>>, encode_tools(erlang:element(5, Req))}]
)
end,
Base@4 = case erlang:element(8, Req) of
{some, Schema} ->
lists:append(
Base@3,
[{<<"output_format"/utf8>>,
gleam@json:object(
[{<<"type"/utf8>>,
gleam@json:string(<<"json_schema"/utf8>>)},
{<<"schema"/utf8>>, Schema}]
)}]
);
none ->
Base@3
end,
Base@5 = case erlang:element(2, Ext) of
{some, Budget} ->
lists:append(
Base@4,
[{<<"thinking"/utf8>>,
gleam@json:object(
[{<<"type"/utf8>>,
gleam@json:string(<<"enabled"/utf8>>)},
{<<"budget_tokens"/utf8>>,
gleam@json:int(Budget)}]
)}]
);
none ->
Base@4
end,
gleam@json:object(Base@5).
-file("src/starlet/anthropic.gleam", 118).
-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),
<<"api.anthropic.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, 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,
<<Base_path/binary, "/v1/messages"/utf8>>
),
_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-api-key"/utf8>>,
Api_key
),
_pipe@8 = gleam@http@request:set_header(
_pipe@7,
<<"anthropic-version"/utf8>>,
<<"2023-06-01"/utf8>>
),
_pipe@9 = set_beta_headers(
_pipe@8,
erlang:element(8, Req),
erlang:element(2, Ext)
),
gleam@http@request:set_body(_pipe@9, Body)
end,
Config = begin
_pipe@10 = gleam@httpc:configure(),
gleam@httpc:timeout(_pipe@10, 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,
<<"anthropic"/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/anthropic.gleam", 84).
?DOC(
" Creates a new Anthropic client with a custom base URL.\n"
" Useful for proxies or self-hosted endpoints.\n"
"\n"
" Note: Anthropic requires max_tokens. If not explicitly set via\n"
" `starlet.max_tokens()`, a default of 4096 is used.\n"
).
-spec new_with_base_url(binary(), binary()) -> starlet:client(ext()).
new_with_base_url(Api_key, Base_url) ->
Config = {provider_config,
<<"anthropic"/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/anthropic.gleam", 75).
?DOC(
" Creates a new Anthropic client with the given API key.\n"
" Uses the default base URL: https://api.anthropic.com\n"
"\n"
" Note: Anthropic requires max_tokens. If not explicitly set via\n"
" `starlet.max_tokens()`, a default of 4096 is used.\n"
).
-spec new(binary()) -> starlet:client(ext()).
new(Api_key) ->
new_with_base_url(Api_key, <<"https://api.anthropic.com"/utf8>>).