7.11 llm: rig 0.46 against vLLM
// crates/llm/src/lib.rs
// Confirmed 0.46 shape (rig issue #3201): OpenAIConfig::new + base_url + connect(ReqwestClient) + client.chat(model).
use async_trait::async_trait;
use bo_core::{ports::LlmProvider, CoreError, Result};
use serde_json::{json, Value};
use rig_core::providers::openai::OpenAIConfig; // VERIFY path
use rig_core::completion::{CompletionRequest, Message}; // VERIFY path
use rig_agent::core::{DynModel, operation::Completion}; // per rig-agent docs example
/// One instance per entry in config/agents/models.yaml.
pub struct VllmProvider {
chat: DynModel<Completion>,
http: reqwest::Client,
base_url: String,
api_key: String,
model: String,
params: Value, // temperature, max_tokens, enable_thinking … from YAML
}
impl VllmProvider {
pub fn new(base_url: &str, api_key: &str, model: &str, params: Value, timeout_sec: u64) -> Self {
let http = reqwest::Client::builder().timeout(std::time::Duration::from_secs(timeout_sec)).build().expect("http client");
let mut cfg = OpenAIConfig::new(api_key);
cfg.base_url = format!("{base_url}/v1");
let client = cfg.connect(rig_reqwest::ReqwestClient::from(http.clone()));
let chat: DynModel<Completion> = client.chat(model).into();
Self { chat, http, base_url: base_url.into(), api_key: api_key.into(), model: model.into(), params }
}
async fn post(&self, path: &str, body: Value) -> Result<Value> {
self.http.post(format!("{}{path}", self.base_url)).bearer_auth(&self.api_key).json(&body).send().await
.map_err(|e| CoreError::Retryable(e.to_string()))?
.error_for_status().map_err(|e| CoreError::Llm(e.to_string()))?
.json().await.map_err(|e| CoreError::Llm(e.to_string()))
}
}
#[async_trait]
impl LlmProvider for VllmProvider {
async fn complete(&self, system: &str, user: &str) -> Result<String> {
// TODO(impl): pass `system` as the preamble through the request builder (VERIFY 0.46 field name)
let req = CompletionRequest::new(Message::user(format!("{system}\n\n{user}")));
let resp = self.chat.call(req).await.map_err(|e| CoreError::Llm(e.to_string()))?;
Ok(resp.output().to_string()) // VERIFY accessor on the 0.46 response
}
/// vLLM structured outputs: `response_format: json_schema` constrains decoding to the schema.
/// Raw HTTP keeps this independent of rig's typed options, which are still changing.
async fn extract_json(&self, system: &str, user: &str, schema: &Value) -> Result<Value> {
let body = json!({
"model": self.model,
"temperature": self.params.get("temperature").cloned().unwrap_or(json!(0)),
"max_tokens": self.params.get("max_tokens").cloned().unwrap_or(json!(4096)),
"messages": [{ "role": "system", "content": system }, { "role": "user", "content": user }],
"response_format": { "type": "json_schema", "json_schema": { "name": "extraction", "schema": schema, "strict": true } },
"chat_template_kwargs": { "enable_thinking": self.params.get("enable_thinking").cloned().unwrap_or(json!(false)) }
});
let v = self.post("/v1/chat/completions", body).await?;
let content = v["choices"][0]["message"]["content"].as_str().ok_or_else(|| CoreError::Llm("empty response".into()))?;
serde_json::from_str(content).map_err(|e| CoreError::Validation(e.to_string()))
}
async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
let v = self.post("/v1/embeddings", json!({ "model": self.model, "input": texts })).await?;
v["data"].as_array().ok_or_else(|| CoreError::Llm("no data".into()))?.iter().map(|d| {
d["embedding"].as_array().ok_or_else(|| CoreError::Llm("no embedding".into()))
.map(|a| a.iter().filter_map(|x| x.as_f64().map(|f| f as f32)).collect())
}).collect()
}
fn model_id(&self) -> &str { &self.model }
}
rig's Tool trait moved in 0.46 and its exact signature is unconfirmed. Tools are therefore defined in config/agents/tools/*.yaml and adapted to rig in crates/llm/src/tools.rs, marked // VERIFY.