Salvor LLM: an asynchronous Rust client for the Anthropic Messages API.
This crate is the shared Messages API client for the Salvor agent runtime.
It was extracted from cargo-mentor's provider module (mentor-provider);
cargo-mentor will consume this crate in turn once it is published. The
crate holds no product logic from either project: only the typed wire
protocol, an async [Client], retry behaviour, and errors.
What it does
[Client::send_message] sends a [MessageRequest] to POST {base_url}/v1/messages and returns a typed [MessageResponse]. The client
is built from a [Config] that sets the base URL, an optional API key, a
retry cap, and a timeout. Retryable failures (429, 500, 529, and
transport errors) are retried with exponential backoff and jitter, honouring
a retry-after header when the server sends one.
Role in Salvor
The agent loop calls this client for every model turn, and
the runtime's budget enforcement consumes the [Usage] reported with each
response, which is why [Usage] is public and central. Tool use is
first-class because the loop's tool dispatch reads
[ContentBlock::ToolUse] blocks and answers them with
[Message::tool_result].
Authentication
Two schemes are supported, selected by [Config::auth_kind]. The default
[AuthKind::ApiKey] sends a standard API key (sk-ant-api...) as the
x-api-key header. [AuthKind::Bearer] sends the key as Authorization: Bearer <key> and adds anthropic-beta: oauth-2025-04-20, which is what an
Anthropic subscription OAuth token (sk-ant-oat..., minted by ant auth)
requires: such a token is rejected on x-api-key and accepted only under
the bearer scheme. In bearer mode x-api-key is never sent. When
[Config::api_key] is None, no auth header is sent under either scheme.
Local endpoints
LM Studio (0.4.1+) and Ollama (0.14+) speak the same Messages wire protocol.
Point [Config::base_url] at the local server and leave [Config::api_key]
unset; the client omits the x-api-key header when no key is configured.
Talking to a local model is a configuration change, not a code path.
Forward compatibility
Responses are deserialized tolerantly. Unknown content-block types are kept
verbatim in [ContentBlock::Unknown], unknown stop reasons in
[StopReason::Other], and unknown top-level fields are ignored. A response
that grows new fields never fails to parse.
Streaming
[Client::stream_message] sends the request with stream: true and returns
a [MessageStream] over the server-sent-events response. Pull typed
[StreamEvent]s one at a time with [MessageStream::next_event] for a live
consumer (a token or cost ticker), or fold the whole stream into a
[MessageResponse] with [MessageStream::get_final_message]. Streaming does
not change what a recording consumer stores: the assembled final message is
byte-for-byte equal in content and usage to what [Client::send_message]
returns for the same request, so which path produced it does not matter.
Only the initial connection is retried; once bytes flow, errors are surfaced
rather than retried, and an error event becomes an [Error].
Not yet included
The request struct is kept minimal: tuning parameters such as thinking and
temperature are not sent, because current Claude models reject several of
them and the defaults are correct. They are expected to arrive in a later
version, additively.
Example
use salvor_llm::{Client, Config, Message, MessageRequest};
# async fn run() -> Result<(), salvor_llm::Error> {
let client = Client::new(Config::from_env())?;
let request = MessageRequest::new("claude-opus-4-8", 1024)
.push_message(Message::user("Explain the `?` operator in Rust."));
let response = client.send_message(&request).await?;
println!("{}", response.text());
# Ok(())
# }