//! Minimal OpenRouter chat-completions client. //! //! Steep 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 Steep 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, pub tool_calls: Vec, pub cost: Option, } #[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, #[serde(skip_serializing_if = "Option::is_none")] pub tool_calls: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub tool_call_id: Option, } impl Message { pub fn new(role: Role, content: impl Into) -> 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, tool_calls: Vec, ) -> 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, content: impl Into) -> 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, description: impl Into, 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; Steep always asks for reasoning. #[derive(Serialize)] struct Reasoning { enabled: bool, } #[derive(Deserialize)] struct ChatResponse { #[serde(default)] choices: Vec, #[serde(default)] usage: Option, } #[derive(Deserialize)] struct Choice { message: ResponseMessage, } #[derive(Deserialize)] struct ResponseMessage { #[serde(default)] content: Option, #[serde(default)] tool_calls: Option>, } /// 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), } #[derive(Deserialize)] struct ContentPart { #[serde(default)] text: Option, } 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::>() .join(""), } } } #[derive(Deserialize)] struct Usage { #[serde(default)] cost: Option, } /// 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: Duration) -> anyhow::Result { Ok(Self { client: reqwest::Client::builder().timeout(timeout).build()?, api_key: String::from(token), model: String::from(model), }) } /// The model every completion is requested from. pub fn model(&self) -> &str { &self.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 { 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 { 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::() .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::(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(" bad gateway "), "bad gateway" ); } }