Работает с серверами OpenAI-совместимого Chat Completions
- LM Studio
- llama.cpp
- vLLM
- mlx-lm
- Azure OpenAI
- OpenAI-совместимые эндпоинты
Стриминг от начала до конца
Тело запроса стримится с диска с точной длиной, ответ возвращается потоком событий. Последнее событие — весь ответ, а drop потока отменяет запрос.
Ничего не теряется молча
ID вызовов инструментов и рассуждения сохраняются при передаче туда и обратно. То, что нельзя представить, — явная ошибка, а не выброшенное поле. Ключи никогда не попадают в ошибки, события и логи.
Инструменты без макросов
Инструмент — это описание и обработчик. Аргументы десериализуются в ваш тип, схему можно вывести из него, а цикл, возвращающий результаты модели, остаётся в ваших руках.
Слои
Middleware вокруг каждого вызова: встроенные Retry, Timeout и Trace, а рядом — замыкание или собственный тип. Клиент без слоёв ничего за них не платит.
Строго и с лимитами
Неизвестный ввод — ошибка, если вы не попросили мягкий режим. У байтов, событий и вызовов инструментов всегда есть лимиты, и упереться в лимит — типизированный исход, а не зависание.
Тесты без модели
Транспорт спрятан за одним трейтом. Подставьте скриптованный бэкенд и тестируйте код, который вызывает модель, без сервера, сети и ключей.
Ответ потоком
Текст приходит событиями, пока модель его пишет. Последнее событие несёт весь ответ: текст, рассуждения, вызовы инструментов, расход токенов и тайминги. client.complete вместо этого дожидается конца, а drop потока отменяет запрос.
use svir::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Error> {
let client = Client::openai("http://127.0.0.1:1234").build()?;
let request = Request::new("qwen3-27b")
.system("Be precise.")
.user("Why do rivers meander?");
let mut stream = client.stream(&request).await?;
while let Some(event) = stream.next().await {
match event? {
Event::Text(piece) => print!("{piece}"),
Event::Completed(done) => println!("\n{:?}", done.usage),
_ => {}
}
}
Ok(())
}
Дайте модели инструменты
Инструмент — это описание и обработчик. Аргументы десериализуются в ваш тип, схему можно вывести из него, а ошибка уходит модели текстом, с которым она может работать. Цикл, возвращающий результаты модели, — несколько строк вашего кода, с ограничением, которое выбираете вы.
Инструментыuse schemars::JsonSchema;
use serde::Deserialize;
use svir::prelude::*;
#[derive(Deserialize, JsonSchema)]
struct City {
/// The city to look up.
city: String,
}
async fn weather(args: City) -> Result<String, String> {
match args.city.to_lowercase().as_str() {
"oslo" => Ok("4 C, light snow".to_owned()),
_ => Err(format!("no weather station in {}", args.city)),
}
}
async fn ask(client: &Client, question: &str) -> Result<String, Error> {
let tools = Tools::new().add("weather", "The weather in a city right now.", weather);
let mut request = Request::new("qwen3-27b").tools(&tools).user(question);
for _ in 0..8 {
let done = client.complete(&request).await?;
if done.calls.is_empty() {
return Ok(done.text);
}
let results = tools.call_all(&done.calls).await;
request = request.assistant(done).tool_results(results);
}
Err(Error::new(ErrorKind::Unsupported).with_detail("the model kept calling tools"))
}
Собирайте слои
Middleware вокруг каждого вызова. Retry, Timeout и Trace встроены; замыкание или собственный тип встают в ту же цепочку. Ничего не повторяется, если ответ уже начался.
use std::time::Duration;
use svir::layer::{Retry, Timeout, Trace};
use svir::prelude::*;
fn client() -> Result<Client, Error> {
Client::openai("https://models.example.com/v1")
.api_key_env("MODELS_API_KEY")
.layer(Retry::transient(3).backoff(Duration::from_millis(250)))
.layer(Timeout::first_token(Duration::from_secs(60)))
.layer(Trace)
.wrap(|request, next| async move {
eprintln!("asking {}", request.model);
next.run(request).await
})
.build()
}
Ретрансляция через прокси
Передавайте байты сервера дальше без изменений и читайте их по пути. Push-декодер не выполняет I/O, поэтому чат-бэкенд может стримить ответ в браузер и при этом сохранить готовый ответ.
Ретрансляция через проксиuse bytes::Bytes;
use svir::openai::chat::Decoder;
use svir::prelude::*;
/// Relays the answer through `forward`, and returns it once it is complete.
async fn relay(
client: &Client,
request: &Request,
mut forward: impl FnMut(Bytes),
) -> Result<Completion, Error> {
let mut raw = client.send(request).await?;
let mut decoder = Decoder::lenient();
let mut answer = None;
while let Some(bytes) = raw.next().await {
let bytes = bytes?;
forward(bytes.clone());
for event in decoder.push(&bytes) {
if let Event::Completed(done) = event? {
answer = Some(done);
}
}
}
decoder.finish()?;
answer.ok_or_else(|| Error::new(ErrorKind::TruncatedStream))
}