Ararajuba: um AI SDK em Rust inspirado no Vercel AI SDK
Na última semana, portei o Vercel AI SDK para Rust e, no processo, redesenhei a interface de providers para ser idiomática em Rust. O resultado é o Ararajuba — um SDK unificado para trabalhar com LLMs (OpenAI, Anthropic, Google, DeepSeek) diretamente em Rust.
Neste post, vou explicar por que fiz isso, o que mudou no processo e como a versão Rust diverge do original em TypeScript.
Por que Rust para AI?
A maioria dos SDKs de IA são em Python ou TypeScript. Mas se você está construindo:
- Backends de alta performance com Axum/Actix
- Agentes que rodam em produção com controle fino de memória
- Ferramentas CLI que precisam de binários pequenos e rápidos
- Sistemas embarcados ou edge computing
...você provavelmente não quer adicionar um runtime Node.js ou Python só para chamar uma API de LLM.
O Vercel AI SDK tem uma abstração excelente — generate_text, stream_text, generate_object, tools, middleware, streaming — mas é TypeScript-only. O Ararajuba traz essa mesma abstração para Rust.
O que foi portado
A API de alto nível é praticamente idêntica:
use ararajuba::{generate_text, GenerateTextOptions, Prompt};
use ararajuba::openai::openai;
let result = generate_text(GenerateTextOptions {
model: &openai("gpt-4o"),
prompt: Prompt::simple("Explique computação quântica em uma frase."),
..Default::default()
}).await?;
println!("{}", result.text);
Streaming:
use ararajuba::{stream_text, GenerateTextOptions, Prompt};
use ararajuba::anthropic::anthropic;
let result = stream_text(GenerateTextOptions {
model: &anthropic("claude-sonnet-4-6"),
prompt: Prompt::simple("Escreva um poema sobre Rust."),
..Default::default()
}).await?;
let mut stream = result.text_stream;
while let Some(chunk) = stream.next().await {
print!("{}", chunk);
}
Tudo que você espera do Vercel AI SDK está aqui: generate_object, stream_object, embed, embed_many, generate_image, generate_speech, transcribe, rerank, tools com multi-step, middleware, MCP client, UI streaming.
Onde divergimos: a interface v4
Aqui é onde fica interessante. A versão inicial (v3) era uma tradução direta do TypeScript. Funcionava, mas não era idiomática em Rust. Na v4, redesenhamos a interface de providers para aproveitar o sistema de tipos do Rust.
1. Async nativo em vez de BoxFuture
v3 (port do TypeScript):
fn call(&self, options: &CallOptions) -> BoxFuture<'_, Result<GenerateResult, Error>>
v4 (Rust-nativo):
#[async_trait]
pub trait LanguageModelV4: Send + Sync {
async fn do_generate(&self, options: &CallOptions) -> Result<GenerateResult, Error>;
async fn do_stream(&self, options: &CallOptions) -> Result<StreamResult, Error>;
}
Sem indireção. O compilador pode inlinar agressivamente.
2. Streams tipados em vez de um enum com 15+ variantes
No TypeScript, o stream retorna eventos misturados em um único canal. Na v4, separamos em 3 streams tipados:
pub struct StreamResult {
pub content: BoxStream<'static, ContentDelta>, // texto, reasoning, arquivos
pub tool_calls: BoxStream<'static, ToolCallDelta>, // chamadas de ferramentas
pub metadata: BoxStream<'static, MetadataDelta>, // usage, finish_reason
pub abort_handle: AbortHandle,
}
O consumidor assina só o que precisa. O compilador garante type safety.
3. Capability traits em vez de flags runtime
No TypeScript, você checa features com model.supportsReasoning. Em Rust, usamos traits:
pub trait SupportsReasoning: LanguageModelV4 {
fn reasoning_config(&self) -> ReasoningConfig;
}
pub trait SupportsCaching: LanguageModelV4 {
fn cache_config(&self) -> CacheConfig;
}
pub trait SupportsToolCalling: LanguageModelV4 {
fn max_tools(&self) -> Option<usize>;
fn supports_parallel_calls(&self) -> bool;
}
Se o modelo não implementa a trait, o código nem compila. Zero overhead em runtime.
4. Options tipadas por provider
Em vez de serde_json::Value genérico:
options.set_provider_options("anthropic.chat", &AnthropicOptions {
thinking: Some(ThinkingConfig {
type_: "enabled",
budget_tokens: 10000,
}),
..Default::default()
})?;
IDE autocomplete funciona. Sem typos em string keys em runtime.
5. Cancelamento via Drop
Quando o stream sai de escopo, a request HTTP é cancelada automaticamente:
{
let result = model.do_stream(&options).await?;
// Se este bloco terminar ou der panic, a request é cancelada
// Sem precisar lembrar de chamar .cancel()
}
// Request já foi cancelada aqui
Rust garante cleanup de recursos via ownership. Você literalmente não consegue esquecer de cancelar.
Sistema de Tools
O sistema de ferramentas suporta execução multi-step com aprovação humana:
let tools = ToolSet::from([
tool("weather")
.description("Pega o clima atual de uma cidade")
.schema(json_schema!({
"type": "object",
"properties": { "location": { "type": "string" } },
"required": ["location"]
}))
.execute(|input| async move {
Ok(format!("25°C e ensolarado em {}", input["location"]))
})
.build(),
]);
let result = generate_text(GenerateTextOptions {
model: &openai("gpt-4o"),
prompt: Prompt::simple("Qual o clima em São Paulo?"),
tools,
max_steps: 5, // permite loops de tool calling
..Default::default()
}).await?;
Para ferramentas perigosas, tem workflow de aprovação:
tool("delete_file")
.needs_approval(|input| {
// Exige aprovação para paths sensíveis
input["path"].as_str()
.map(|p| p.contains("/etc") || p.contains("system"))
.unwrap_or(false)
})
.execute(|input| async move { /* ... */ })
.build()
Middleware
Middleware intercepta e transforma chamadas ao modelo:
use ararajuba::{wrap_language_model, extract_reasoning_middleware};
let model = wrap_language_model(
anthropic("claude-opus-4-6"),
extract_reasoning_middleware(),
);
// Agora <thinking>...</thinking> é extraído automaticamente
A inovação: middleware pode chamar tanto do_generate quanto do_stream, permitindo cenários como "simular streaming a partir de uma API não-streaming" ou "cachear resultados de streaming".
MCP (Model Context Protocol)
Cliente MCP integrado com 3 transportes:
use ararajuba::mcp::{create_mcp_client, StdioTransportConfig};
let mcp = create_mcp_client(StdioTransportConfig {
command: "npx".into(),
args: vec!["@anthropic/mcp-server-github".into()],
..Default::default()
}).await?;
let tools = mcp.tools().await?;
// Todas as ferramentas MCP viram ToolSet do Ararajuba
Arquitetura
ararajuba/ # Facade crate (o que o usuário importa)
core/
├── provider # Traits: LanguageModel, EmbeddingModel, ImageModel, etc.
├── provider-utils # HTTP, SSE parsing, retry, API key loading
├── ai # High-level API: generate_text, tools, middleware, agent
└── mcp # MCP client com stdio/HTTP/SSE
providers/
├── openai-compatible # Base para APIs compatíveis com OpenAI
├── openai # OpenAI (GPT-4o, o1/o3/o4, GPT-5, DALL-E, TTS, Whisper)
├── anthropic # Anthropic (Claude 3.5/4/4.5/4.6, thinking, caching)
├── google # Google (Gemini, Imagen, Veo)
└── deepseek # DeepSeek (chat, reasoner)
tools/
└── coding # Tools prontas: file system, git, shell, diagnostics
11 crates, 307 testes, edição Rust 2024.
Compatibilidade com Frontend
O protocolo de UI streaming (UIMessageStream, formato SSE) é compatível com os hooks do @ai-sdk/react (useChat, useCompletion, useObject). Se seu frontend já consome o protocolo SSE padrão do Vercel AI SDK, funciona com Ararajuba como backend.
Instalação
[dependencies]
ararajuba = { version = "0.1", features = ["openai", "anthropic"] }
Features disponíveis: openai, anthropic, google, deepseek, mcp, coding-tools, full.
Links
- GitHub: github.com/structlylabs/ararajuba-sdk
- crates.io: crates.io/crates/ararajuba
O Ararajuba é open source (Apache-2.0). Contribuições são bem-vindas — especialmente se você quer adicionar novos providers ou melhorar os existentes.
Se você trabalha com Rust e LLMs, experimenta e me conta o que achou.