ci/woodpecker/push/tests Pipeline was successful
limit tool result to open router tool response (with truncated info for ai) Hard kill if graceful shutdown is too long
474 lines
13 KiB
Rust
474 lines
13 KiB
Rust
//! 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 anyhow::Context;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use tracing::instrument;
|
|
|
|
/// OpenRouter API root, version prefix included.
|
|
const BASE_URL: &str = "https://openrouter.ai/api/v1";
|
|
|
|
/// What the model is allowed to do on a given turn.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ToolChoice {
|
|
/// The model decides whether to call a tool.
|
|
Auto,
|
|
/// The model must answer, without calling any tool. The tools stay declared,
|
|
/// so the conversation remains the same shape as on the other turns.
|
|
None,
|
|
}
|
|
|
|
impl ToolChoice {
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Auto => "auto",
|
|
Self::None => "none",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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: reqwest::Client,
|
|
api_key: String,
|
|
model: String,
|
|
}
|
|
|
|
impl OpenRouterClient {
|
|
pub fn new(token: &str, model: &str, timeout: u64) -> anyhow::Result<Self> {
|
|
Ok(Self {
|
|
client: reqwest::Client::builder()
|
|
.timeout(Duration::from_secs(timeout))
|
|
.build()?,
|
|
api_key: String::from(token),
|
|
model: String::from(model),
|
|
})
|
|
}
|
|
|
|
/// Sends a conversation with tool definitions and returns either a final
|
|
/// message or the tool calls requested by the model.
|
|
///
|
|
/// The conversation is borrowed rather than taken by value: the caller keeps
|
|
/// appending to it between iterations, and a copy per iteration would be pure
|
|
/// waste.
|
|
#[instrument(skip(self, messages, tools), err)]
|
|
pub async fn chat_with_tools(
|
|
&self,
|
|
messages: &[Message],
|
|
tools: &[Tool],
|
|
tool_choice: ToolChoice,
|
|
) -> anyhow::Result<ToolChatResult> {
|
|
let response = self.complete(messages, tools, tool_choice).await?;
|
|
|
|
let cost = response.usage.and_then(|usage| usage.cost);
|
|
let message = response
|
|
.choices
|
|
.into_iter()
|
|
.next()
|
|
.ok_or_else(|| anyhow::anyhow!("No choices in response"))?
|
|
.message;
|
|
|
|
Ok(ToolChatResult {
|
|
message: message.content.map(Content::into_text),
|
|
tool_calls: message.tool_calls.unwrap_or_default(),
|
|
cost,
|
|
})
|
|
}
|
|
|
|
async fn complete(
|
|
&self,
|
|
messages: &[Message],
|
|
tools: &[Tool],
|
|
tool_choice: ToolChoice,
|
|
) -> anyhow::Result<ChatResponse> {
|
|
let request = ChatRequest {
|
|
model: &self.model,
|
|
messages,
|
|
reasoning: Reasoning { enabled: true },
|
|
tools,
|
|
tool_choice: tool_choice.as_str(),
|
|
};
|
|
|
|
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: ToolChoice::Auto.as_str(),
|
|
})
|
|
.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 a_forced_answer_turn_disables_the_tools() {
|
|
assert_eq!(ToolChoice::None.as_str(), "none");
|
|
assert_eq!(ToolChoice::Auto.as_str(), "auto");
|
|
}
|
|
|
|
#[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>"
|
|
);
|
|
}
|
|
}
|