Replace openrouter-rs with in-tree client and require sandbox

Remove the openrouter-rs dependency in favor of a minimal in-tree
OpenRouter chat-completions client, and drop the BOT_NAME and
SANDBOX_ENABLED config options. Reviews now always run inside the
sandbox, and the review prompt asks the model to read files with the
available tools instead of embedding the diff.
This commit is contained in:
qpismont committed 2026-09-17 14:48:42 +00:00
1 parent ee221b0954
commit 04cc172848
15 files changed
+1301 -740

No files matched your search

+402 -53
View File
@@ -1,63 +1,253 @@
//! Minimal OpenRouter chat-completions client.
//!
//! Herald only needs a non-streaming `POST /chat/completions` with optional
//! tool calling, so the wire types are implemented in-tree instead of pulling a
//! third-party SDK (and its own `reqwest` version) into the workspace.
//!
//! Only the response fields Herald consumes are modelled: `content`,
//! `tool_calls` and `usage.cost`. Unknown fields are ignored.
use std::time::Duration;
use openrouter_rs::{
Message,
api::chat::ChatCompletionRequest,
types::{Tool, ToolCall},
};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::instrument;
pub struct ChatResult {
pub message: String,
pub cost: Option<f64>,
}
/// OpenRouter API root, version prefix included.
const BASE_URL: &str = "https://openrouter.ai/api/v1";
/// The model decides on its own which tool to call.
const TOOL_CHOICE_AUTO: &str = "auto";
/// Result of a completion that may contain tool calls.
pub struct ToolChatResult {
pub message: Option<String>,
pub tool_calls: Vec<ToolCall>,
pub cost: Option<f64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Role {
System,
User,
Assistant,
Tool,
}
/// A single turn of the conversation.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Message {
pub role: Role,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
impl Message {
pub fn new(role: Role, content: impl Into<String>) -> Self {
Self {
role,
content: Some(content.into()),
tool_calls: None,
tool_call_id: None,
}
}
/// Assistant turn requesting tool calls. Models often answer with tool
/// calls but no text, in which case the content is sent as `null`.
pub fn assistant_with_tool_calls(
content: impl Into<String>,
tool_calls: Vec<ToolCall>,
) -> Self {
let content = content.into();
Self {
role: Role::Assistant,
content: (!content.is_empty()).then_some(content),
tool_calls: Some(tool_calls),
tool_call_id: None,
}
}
/// Result of a tool call, linked to the request by `tool_call_id`.
pub fn tool_response(tool_call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self {
role: Role::Tool,
content: Some(content.into()),
tool_calls: None,
tool_call_id: Some(tool_call_id.into()),
}
}
}
/// A tool the model may call.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct Tool {
#[serde(rename = "type", default = "function_type")]
pub kind: String,
pub function: FunctionDefinition,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FunctionDefinition {
pub name: String,
pub description: String,
/// JSON schema describing the accepted arguments.
pub parameters: Value,
}
impl Tool {
pub fn new(name: impl Into<String>, description: impl Into<String>, parameters: Value) -> Self {
Self {
kind: function_type(),
function: FunctionDefinition {
name: name.into(),
description: description.into(),
parameters,
},
}
}
}
/// A tool call requested by the model.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type", default = "function_type")]
pub kind: String,
pub function: FunctionCall,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FunctionCall {
pub name: String,
/// Arguments as a JSON-encoded string. Kept verbatim so that echoing the
/// call back into the conversation does not re-encode or corrupt it.
pub arguments: String,
}
impl ToolCall {
pub fn id(&self) -> &str {
&self.id
}
pub fn name(&self) -> &str {
&self.function.name
}
pub fn arguments_json(&self) -> &str {
&self.function.arguments
}
}
fn function_type() -> String {
String::from("function")
}
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: &'a [Message],
reasoning: Reasoning,
tools: &'a [Tool],
tool_choice: &'a str,
}
/// OpenRouter reasoning config; Herald always asks for reasoning.
#[derive(Serialize)]
struct Reasoning {
enabled: bool,
}
#[derive(Deserialize)]
struct ChatResponse {
#[serde(default)]
choices: Vec<Choice>,
#[serde(default)]
usage: Option<Usage>,
}
#[derive(Deserialize)]
struct Choice {
message: ResponseMessage,
}
#[derive(Deserialize)]
struct ResponseMessage {
#[serde(default)]
content: Option<Content>,
#[serde(default)]
tool_calls: Option<Vec<ToolCall>>,
}
/// Message content is a plain string for most models and a list of typed parts
/// for others; both are collapsed to text.
#[derive(Deserialize)]
#[serde(untagged)]
enum Content {
Text(String),
Parts(Vec<ContentPart>),
}
#[derive(Deserialize)]
struct ContentPart {
#[serde(default)]
text: Option<String>,
}
impl Content {
fn into_text(self) -> String {
match self {
Self::Text(text) => text,
Self::Parts(parts) => parts
.into_iter()
.filter_map(|part| part.text)
.collect::<Vec<_>>()
.join(""),
}
}
}
#[derive(Deserialize)]
struct Usage {
#[serde(default)]
cost: Option<f64>,
}
/// Error payload returned by OpenRouter for a failed request.
#[derive(Deserialize)]
struct ErrorResponse {
error: ErrorDetail,
}
#[derive(Deserialize)]
struct ErrorDetail {
message: String,
}
#[derive(Clone)]
pub struct OpenRouterClient {
client: openrouter_rs::OpenRouterClient,
client: reqwest::Client,
api_key: String,
model: String,
}
impl OpenRouterClient {
pub fn new(token: &str, model: &str, timeout: u64) -> anyhow::Result<Self> {
Ok(Self {
client: openrouter_rs::OpenRouterClient::builder()
.api_key(token)
.http_client(
reqwest::Client::builder()
.timeout(Duration::from_secs(timeout))
.build()?,
)
client: reqwest::Client::builder()
.timeout(Duration::from_secs(timeout))
.build()?,
api_key: String::from(token),
model: String::from(model),
})
}
#[instrument(skip(self), err)]
pub async fn chat(&self, msg: &str) -> anyhow::Result<ChatResult> {
let request = ChatCompletionRequest::builder()
.model(&self.model)
.enable_reasoning()
.messages(vec![Message::new(openrouter_rs::types::Role::User, msg)])
.build()?;
let response = self.client.chat().create(&request).await?;
Ok(ChatResult {
message: response.choices[0]
.content()
.map(String::from)
.ok_or(anyhow::anyhow!("No content"))?,
cost: response.usage.and_then(|u| u.cost),
})
}
/// Sends a conversation with tool definitions and returns either a final
/// message or the tool calls requested by the model.
#[instrument(skip(self, messages, tools), err)]
@@ -66,27 +256,186 @@ impl OpenRouterClient {
messages: Vec<Message>,
tools: Vec<Tool>,
) -> anyhow::Result<ToolChatResult> {
let request = ChatCompletionRequest::builder()
.model(&self.model)
.enable_reasoning()
.messages(messages)
.tools(tools)
.tool_choice_auto()
.build()?;
let response = self.complete(&messages, &tools).await?;
let response = self.client.chat().create(&request).await?;
let choice = response
let cost = response.usage.and_then(|usage| usage.cost);
let message = response
.choices
.first()
.ok_or_else(|| anyhow::anyhow!("No choices in response"))?;
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("No choices in response"))?
.message;
Ok(ToolChatResult {
message: choice.content().map(String::from),
tool_calls: choice
.tool_calls()
.map(<[ToolCall]>::to_vec)
.unwrap_or_default(),
cost: response.usage.and_then(|u| u.cost),
message: message.content.map(Content::into_text),
tool_calls: message.tool_calls.unwrap_or_default(),
cost,
})
}
async fn complete(&self, messages: &[Message], tools: &[Tool]) -> anyhow::Result<ChatResponse> {
let request = ChatRequest {
model: &self.model,
messages,
reasoning: Reasoning { enabled: true },
tools,
tool_choice: TOOL_CHOICE_AUTO,
};
let response = self
.client
.post(format!("{BASE_URL}/chat/completions"))
.bearer_auth(&self.api_key)
.json(&request)
.send()
.await
.context("failed to reach OpenRouter")?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
anyhow::bail!("OpenRouter returned {status}: {}", error_message(&body));
}
response
.json::<ChatResponse>()
.await
.context("invalid OpenRouter response")
}
}
/// Extracts the message from an OpenRouter error body, falling back to the raw
/// body when it is not the expected JSON shape.
fn error_message(body: &str) -> String {
serde_json::from_str::<ErrorResponse>(body)
.map(|response| response.error.message)
.unwrap_or_else(|_| body.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// Builds a tool call the way the API returns one, so the fixture also
/// covers deserialization.
fn tool_call(id: &str, name: &str, arguments: &str) -> ToolCall {
serde_json::from_value(json!({
"id": id,
"type": "function",
"function": { "name": name, "arguments": arguments }
}))
.unwrap()
}
#[test]
fn tool_call_arguments_are_kept_verbatim() {
let call = tool_call("call_1", "grep", r#"{"pattern":"fn main"}"#);
let message = Message::assistant_with_tool_calls("", vec![call]);
let serialized = serde_json::to_value(&message).unwrap();
assert_eq!(serialized["content"], Value::Null);
assert_eq!(serialized["tool_calls"][0]["type"], "function");
assert_eq!(serialized["tool_calls"][0]["function"]["name"], "grep");
assert_eq!(
serialized["tool_calls"][0]["function"]["arguments"],
r#"{"pattern":"fn main"}"#
);
}
#[test]
fn tool_response_carries_role_and_tool_call_id() {
let message = Message::tool_response("call_1", "src/main.rs");
let serialized = serde_json::to_value(&message).unwrap();
assert_eq!(serialized["role"], "tool");
assert_eq!(serialized["tool_call_id"], "call_1");
assert_eq!(serialized["content"], "src/main.rs");
}
#[test]
fn request_serializes_tool_choice_and_reasoning() {
let messages = [Message::new(Role::User, "hi")];
let tools = [Tool::new("ls", "List files", json!({"type": "object"}))];
let request = serde_json::to_value(ChatRequest {
model: "some/model",
messages: &messages,
reasoning: Reasoning { enabled: true },
tools: &tools,
tool_choice: TOOL_CHOICE_AUTO,
})
.unwrap();
assert_eq!(request["model"], "some/model");
assert_eq!(request["reasoning"]["enabled"], true);
assert_eq!(request["tool_choice"], "auto");
assert_eq!(request["tools"][0]["function"]["name"], "ls");
assert_eq!(request["messages"][0]["role"], "user");
}
#[test]
fn response_parses_tool_calls_and_cost() {
let response: ChatResponse = serde_json::from_value(json!({
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": { "name": "ls", "arguments": "{\"path\":\"src\"}" }
}]
}
}],
"usage": { "cost": 0.0021 }
}))
.unwrap();
assert_eq!(response.usage.and_then(|usage| usage.cost), Some(0.0021));
let message = response.choices.into_iter().next().unwrap().message;
assert!(message.content.is_none());
let calls = message.tool_calls.unwrap();
assert_eq!(calls[0].name(), "ls");
assert_eq!(calls[0].id(), "call_1");
assert_eq!(calls[0].arguments_json(), r#"{"path":"src"}"#);
}
#[test]
fn response_parses_parts_content() {
let response: ChatResponse = serde_json::from_value(json!({
"choices": [{
"message": {
"role": "assistant",
"content": [
{ "type": "text", "text": "hello " },
{ "type": "text", "text": "world" }
]
}
}]
}))
.unwrap();
assert!(response.usage.is_none());
let message = response.choices.into_iter().next().unwrap().message;
assert_eq!(message.content.unwrap().into_text(), "hello world");
}
#[test]
fn error_message_prefers_api_message() {
let body = r#"{"error":{"message":"No auth credentials found","code":401}}"#;
assert_eq!(error_message(body), "No auth credentials found");
}
#[test]
fn error_message_falls_back_to_raw_body() {
assert_eq!(
error_message(" <html>bad gateway</html> "),
"<html>bad gateway</html>"
);
}
}