Перейти к основному содержимому

svir

Маленький компонуемый Rust SDK для общения с большими языковыми моделями.

Протокол обмена между вашим приложением и сервером модели — и ничего лишнего.

Работает с серверами OpenAI-совместимого Chat Completions

  • LM Studio
  • llama.cpp
  • vLLM
  • mlx-lm
  • Azure OpenAI
  • OpenAI-совместимые эндпоинты

Стриминг от начала до конца

Тело запроса стримится с диска с точной длиной, ответ возвращается потоком событий. Последнее событие — весь ответ, а drop потока отменяет запрос.

Ничего не теряется молча

ID вызовов инструментов и рассуждения сохраняются при передаче туда и обратно. То, что нельзя представить, — явная ошибка, а не выброшенное поле. Ключи никогда не попадают в ошибки, события и логи.

Инструменты без макросов

Инструмент — это описание и обработчик. Аргументы десериализуются в ваш тип, схему можно вывести из него, а цикл, возвращающий результаты модели, остаётся в ваших руках.

Слои

Middleware вокруг каждого вызова: встроенные Retry, Timeout и Trace, а рядом — замыкание или собственный тип. Клиент без слоёв ничего за них не платит.

Строго и с лимитами

Неизвестный ввод — ошибка, если вы не попросили мягкий режим. У байтов, событий и вызовов инструментов всегда есть лимиты, и упереться в лимит — типизированный исход, а не зависание.

Тесты без модели

Транспорт спрятан за одним трейтом. Подставьте скриптованный бэкенд и тестируйте код, который вызывает модель, без сервера, сети и ключей.

01

Ответ потоком

Текст приходит событиями, пока модель его пишет. Последнее событие несёт весь ответ: текст, рассуждения, вызовы инструментов, расход токенов и тайминги. 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(())
}
02

Дайте модели инструменты

Инструмент — это описание и обработчик. Аргументы десериализуются в ваш тип, схему можно вывести из него, а ошибка уходит модели текстом, с которым она может работать. Цикл, возвращающий результаты модели, — несколько строк вашего кода, с ограничением, которое выбираете вы.

Инструменты
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"))
}
03

Собирайте слои

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()
}
04

Ретрансляция через прокси

Передавайте байты сервера дальше без изменений и читайте их по пути. 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))
}

Название

Свирь — река, которая соединяет Онежское озеро с Ладожским; дальше эту воду несёт в Балтику Нева. svir — из одной семьи с volga (HTTP) и neva (MCP), и, как река, давшая ей имя, она соединяет два уже существующих водоёма: ваше приложение и сервер модели.