feat: add agent structure and improve tooling for provider
This commit is contained in:
parent
e2c966d31e
commit
b341fdb368
21 changed files with 2567 additions and 443 deletions
|
|
@ -1,4 +1,8 @@
|
|||
use crate::providers::traits::{ChatResponse as ProviderChatResponse, Provider};
|
||||
use crate::providers::traits::{
|
||||
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
|
||||
Provider, ToolCall as ProviderToolCall,
|
||||
};
|
||||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -26,13 +30,76 @@ struct Message {
|
|||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiChatResponse {
|
||||
struct ChatResponse {
|
||||
content: Vec<ContentBlock>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ContentBlock {
|
||||
text: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeChatRequest {
|
||||
model: String,
|
||||
max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system: Option<String>,
|
||||
messages: Vec<NativeMessage>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<NativeToolSpec>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeMessage {
|
||||
role: String,
|
||||
content: Vec<NativeContentOut>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
enum NativeContentOut {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult { tool_use_id: String, content: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeToolSpec {
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChatResponse {
|
||||
#[serde(default)]
|
||||
content: Vec<NativeContentIn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeContentIn {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
text: Option<String>,
|
||||
#[serde(default)]
|
||||
id: Option<String>,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
|
|
@ -62,6 +129,186 @@ impl AnthropicProvider {
|
|||
fn is_setup_token(token: &str) -> bool {
|
||||
token.starts_with("sk-ant-oat01-")
|
||||
}
|
||||
|
||||
fn apply_auth(
|
||||
&self,
|
||||
request: reqwest::RequestBuilder,
|
||||
credential: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
if Self::is_setup_token(credential) {
|
||||
request.header("Authorization", format!("Bearer {credential}"))
|
||||
} else {
|
||||
request.header("x-api-key", credential)
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
|
||||
let items = tools?;
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
items
|
||||
.iter()
|
||||
.map(|tool| NativeToolSpec {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
input_schema: tool.parameters.clone(),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_assistant_tool_call_message(content: &str) -> Option<Vec<NativeContentOut>> {
|
||||
let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
|
||||
let tool_calls = value
|
||||
.get("tool_calls")
|
||||
.and_then(|v| serde_json::from_value::<Vec<ProviderToolCall>>(v.clone()).ok())?;
|
||||
|
||||
let mut blocks = Vec::new();
|
||||
if let Some(text) = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty())
|
||||
{
|
||||
blocks.push(NativeContentOut::Text {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
for call in tool_calls {
|
||||
let input = serde_json::from_str::<serde_json::Value>(&call.arguments)
|
||||
.unwrap_or_else(|_| serde_json::Value::Object(serde_json::Map::new()));
|
||||
blocks.push(NativeContentOut::ToolUse {
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
input,
|
||||
});
|
||||
}
|
||||
Some(blocks)
|
||||
}
|
||||
|
||||
fn parse_tool_result_message(content: &str) -> Option<NativeMessage> {
|
||||
let value = serde_json::from_str::<serde_json::Value>(content).ok()?;
|
||||
let tool_use_id = value
|
||||
.get("tool_call_id")
|
||||
.and_then(serde_json::Value::as_str)?
|
||||
.to_string();
|
||||
let result = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some(NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![NativeContentOut::ToolResult {
|
||||
tool_use_id,
|
||||
content: result,
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<NativeMessage>) {
|
||||
let mut system_prompt = None;
|
||||
let mut native_messages = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
match msg.role.as_str() {
|
||||
"system" => {
|
||||
if system_prompt.is_none() {
|
||||
system_prompt = Some(msg.content.clone());
|
||||
}
|
||||
}
|
||||
"assistant" => {
|
||||
if let Some(blocks) = Self::parse_assistant_tool_call_message(&msg.content) {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: blocks,
|
||||
});
|
||||
} else {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![NativeContentOut::Text {
|
||||
text: msg.content.clone(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
"tool" => {
|
||||
if let Some(tool_result) = Self::parse_tool_result_message(&msg.content) {
|
||||
native_messages.push(tool_result);
|
||||
} else {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![NativeContentOut::Text {
|
||||
text: msg.content.clone(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
native_messages.push(NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![NativeContentOut::Text {
|
||||
text: msg.content.clone(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(system_prompt, native_messages)
|
||||
}
|
||||
|
||||
fn parse_text_response(response: ChatResponse) -> anyhow::Result<String> {
|
||||
response
|
||||
.content
|
||||
.into_iter()
|
||||
.find(|c| c.kind == "text")
|
||||
.and_then(|c| c.text)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Anthropic"))
|
||||
}
|
||||
|
||||
fn parse_native_response(response: NativeChatResponse) -> ProviderChatResponse {
|
||||
let mut text_parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
|
||||
for block in response.content {
|
||||
match block.kind.as_str() {
|
||||
"text" => {
|
||||
if let Some(text) = block.text.map(|t| t.trim().to_string()) {
|
||||
if !text.is_empty() {
|
||||
text_parts.push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
let name = block.name.unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let arguments = block
|
||||
.input
|
||||
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new()));
|
||||
tool_calls.push(ProviderToolCall {
|
||||
id: block.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name,
|
||||
arguments: arguments.to_string(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
ProviderChatResponse {
|
||||
text: if text_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(text_parts.join("\n"))
|
||||
},
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -72,7 +319,7 @@ impl Provider for AnthropicProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)."
|
||||
|
|
@ -97,11 +344,7 @@ impl Provider for AnthropicProvider {
|
|||
.header("content-type", "application/json")
|
||||
.json(&request);
|
||||
|
||||
if Self::is_setup_token(credential) {
|
||||
request = request.header("Authorization", format!("Bearer {credential}"));
|
||||
} else {
|
||||
request = request.header("x-api-key", credential);
|
||||
}
|
||||
request = self.apply_auth(request, credential);
|
||||
|
||||
let response = request.send().await?;
|
||||
|
||||
|
|
@ -109,14 +352,50 @@ impl Provider for AnthropicProvider {
|
|||
return Err(super::api_error("Anthropic", response).await);
|
||||
}
|
||||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
let chat_response: ChatResponse = response.json().await?;
|
||||
Self::parse_text_response(chat_response)
|
||||
}
|
||||
|
||||
chat_response
|
||||
.content
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| ProviderChatResponse::with_text(c.text))
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Anthropic"))
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ProviderChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let credential = self.credential.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Anthropic credentials not set. Set ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN (setup-token)."
|
||||
)
|
||||
})?;
|
||||
|
||||
let (system_prompt, messages) = Self::convert_messages(request.messages);
|
||||
let native_request = NativeChatRequest {
|
||||
model: model.to_string(),
|
||||
max_tokens: 4096,
|
||||
system: system_prompt,
|
||||
messages,
|
||||
temperature,
|
||||
tools: Self::convert_tools(request.tools),
|
||||
};
|
||||
|
||||
let req = self
|
||||
.client
|
||||
.post(format!("{}/v1/messages", self.base_url))
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.header("content-type", "application/json")
|
||||
.json(&native_request);
|
||||
|
||||
let response = self.apply_auth(req, credential).send().await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(super::api_error("Anthropic", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
Ok(Self::parse_native_response(native_response))
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -241,15 +520,16 @@ mod tests {
|
|||
#[test]
|
||||
fn chat_response_deserializes() {
|
||||
let json = r#"{"content":[{"type":"text","text":"Hello there!"}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.content.len(), 1);
|
||||
assert_eq!(resp.content[0].text, "Hello there!");
|
||||
assert_eq!(resp.content[0].kind, "text");
|
||||
assert_eq!(resp.content[0].text.as_deref(), Some("Hello there!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_response_empty_content() {
|
||||
let json = r#"{"content":[]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.content.is_empty());
|
||||
}
|
||||
|
||||
|
|
@ -257,10 +537,10 @@ mod tests {
|
|||
fn chat_response_multiple_blocks() {
|
||||
let json =
|
||||
r#"{"content":[{"type":"text","text":"First"},{"type":"text","text":"Second"}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.content.len(), 2);
|
||||
assert_eq!(resp.content[0].text, "First");
|
||||
assert_eq!(resp.content[1].text, "Second");
|
||||
assert_eq!(resp.content[0].text.as_deref(), Some("First"));
|
||||
assert_eq!(resp.content[1].text.as_deref(), Some("Second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
//! Most LLM APIs follow the same `/v1/chat/completions` format.
|
||||
//! This module provides a single implementation that works for all of them.
|
||||
|
||||
use crate::providers::traits::{ChatMessage, ChatResponse, Provider, ToolCall};
|
||||
use crate::providers::traits::{
|
||||
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
|
||||
Provider, ToolCall as ProviderToolCall,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -163,12 +166,11 @@ struct ResponseMessage {
|
|||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<ApiToolCall>>,
|
||||
tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct ApiToolCall {
|
||||
id: Option<String>,
|
||||
struct ToolCall {
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
function: Option<Function>,
|
||||
|
|
@ -254,44 +256,6 @@ fn extract_responses_text(response: ResponsesResponse) -> Option<String> {
|
|||
None
|
||||
}
|
||||
|
||||
fn map_response_message(message: ResponseMessage) -> ChatResponse {
|
||||
let text = first_nonempty(message.content.as_deref());
|
||||
let tool_calls = message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, call)| map_api_tool_call(call, index))
|
||||
.collect();
|
||||
|
||||
ChatResponse { text, tool_calls }
|
||||
}
|
||||
|
||||
fn map_api_tool_call(call: ApiToolCall, index: usize) -> Option<ToolCall> {
|
||||
if call.kind.as_deref().is_some_and(|kind| kind != "function") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let function = call.function?;
|
||||
let name = function
|
||||
.name
|
||||
.and_then(|value| first_nonempty(Some(value.as_str())))?;
|
||||
let arguments = function
|
||||
.arguments
|
||||
.and_then(|value| first_nonempty(Some(value.as_str())))
|
||||
.unwrap_or_else(|| "{}".to_string());
|
||||
let id = call
|
||||
.id
|
||||
.and_then(|value| first_nonempty(Some(value.as_str())))
|
||||
.unwrap_or_else(|| format!("call_{}", index + 1));
|
||||
|
||||
Some(ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
})
|
||||
}
|
||||
|
||||
impl OpenAiCompatibleProvider {
|
||||
fn apply_auth_header(
|
||||
&self,
|
||||
|
|
@ -311,7 +275,7 @@ impl OpenAiCompatibleProvider {
|
|||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
model: &str,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let request = ResponsesRequest {
|
||||
model: model.to_string(),
|
||||
input: vec![ResponsesInput {
|
||||
|
|
@ -337,7 +301,6 @@ impl OpenAiCompatibleProvider {
|
|||
let responses: ResponsesResponse = response.json().await?;
|
||||
|
||||
extract_responses_text(responses)
|
||||
.map(ChatResponse::with_text)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from {} Responses API", self.name))
|
||||
}
|
||||
}
|
||||
|
|
@ -350,7 +313,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"{} API key not set. Run `zeroclaw onboard` or set the appropriate env var.",
|
||||
|
|
@ -408,13 +371,27 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
|
||||
let choice = chat_response
|
||||
chat_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))?;
|
||||
|
||||
Ok(map_response_message(choice.message))
|
||||
.map(|c| {
|
||||
// If tool_calls are present, serialize the full message as JSON
|
||||
// so parse_tool_calls can handle the OpenAI-style format
|
||||
if c.message.tool_calls.is_some()
|
||||
&& c.message
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map_or(false, |t| !t.is_empty())
|
||||
{
|
||||
serde_json::to_string(&c.message)
|
||||
.unwrap_or_else(|_| c.message.content.unwrap_or_default())
|
||||
} else {
|
||||
// No tool calls, return content as-is
|
||||
c.message.content.unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
|
|
@ -422,7 +399,7 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"{} API key not set. Run `zeroclaw onboard` or set the appropriate env var.",
|
||||
|
|
@ -482,13 +459,71 @@ impl Provider for OpenAiCompatibleProvider {
|
|||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
|
||||
let choice = chat_response
|
||||
chat_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))?;
|
||||
.map(|c| {
|
||||
// If tool_calls are present, serialize the full message as JSON
|
||||
// so parse_tool_calls can handle the OpenAI-style format
|
||||
if c.message.tool_calls.is_some()
|
||||
&& c.message
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map_or(false, |t| !t.is_empty())
|
||||
{
|
||||
serde_json::to_string(&c.message)
|
||||
.unwrap_or_else(|_| c.message.content.unwrap_or_default())
|
||||
} else {
|
||||
// No tool calls, return content as-is
|
||||
c.message.content.unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from {}", self.name))
|
||||
}
|
||||
|
||||
Ok(map_response_message(choice.message))
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ProviderChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let text = self
|
||||
.chat_with_history(request.messages, model, temperature)
|
||||
.await?;
|
||||
|
||||
// Backward compatible path: chat_with_history may serialize tool_calls JSON into content.
|
||||
if let Ok(message) = serde_json::from_str::<ResponseMessage>(&text) {
|
||||
let tool_calls = message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|tc| {
|
||||
let function = tc.function?;
|
||||
let name = function.name?;
|
||||
let arguments = function.arguments.unwrap_or_else(|| "{}".to_string());
|
||||
Some(ProviderToolCall {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
arguments,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
return Ok(ProviderChatResponse {
|
||||
text: message.content,
|
||||
tool_calls,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ProviderChatResponse {
|
||||
text: Some(text),
|
||||
tool_calls: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -573,20 +608,6 @@ mod tests {
|
|||
assert!(resp.choices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_with_tool_calls_maps_structured_data() {
|
||||
let json = r#"{"choices":[{"message":{"content":"Running checks","tool_calls":[{"id":"call_1","type":"function","function":{"name":"shell","arguments":"{\"command\":\"pwd\"}"}}]}}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let choice = resp.choices.into_iter().next().unwrap();
|
||||
|
||||
let mapped = map_response_message(choice.message);
|
||||
assert_eq!(mapped.text.as_deref(), Some("Running checks"));
|
||||
assert_eq!(mapped.tool_calls.len(), 1);
|
||||
assert_eq!(mapped.tool_calls[0].id, "call_1");
|
||||
assert_eq!(mapped.tool_calls[0].name, "shell");
|
||||
assert_eq!(mapped.tool_calls[0].arguments, r#"{"command":"pwd"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x_api_key_auth_style() {
|
||||
let p = OpenAiCompatibleProvider::new(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
//! - Gemini CLI OAuth tokens (reuse existing ~/.gemini/ authentication)
|
||||
//! - Google Cloud ADC (`GOOGLE_APPLICATION_CREDENTIALS`)
|
||||
|
||||
use crate::providers::traits::{ChatResponse, Provider};
|
||||
use crate::providers::traits::Provider;
|
||||
use async_trait::async_trait;
|
||||
use directories::UserDirs;
|
||||
use reqwest::Client;
|
||||
|
|
@ -260,7 +260,7 @@ impl Provider for GeminiProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let auth = self.auth.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Gemini API key not found. Options:\n\
|
||||
|
|
@ -319,7 +319,6 @@ impl Provider for GeminiProvider {
|
|||
.and_then(|c| c.into_iter().next())
|
||||
.and_then(|c| c.content.parts.into_iter().next())
|
||||
.and_then(|p| p.text)
|
||||
.map(ChatResponse::with_text)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from Gemini"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ pub mod router;
|
|||
pub mod traits;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use traits::{ChatMessage, ChatResponse, Provider, ToolCall};
|
||||
pub use traits::{
|
||||
ChatMessage, ChatRequest, ChatResponse, ConversationMessage, Provider, ToolCall,
|
||||
ToolResultMessage,
|
||||
};
|
||||
|
||||
use compatible::{AuthStyle, OpenAiCompatibleProvider};
|
||||
use reliable::ReliableProvider;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use crate::providers::traits::{ChatResponse as ProviderChatResponse, Provider};
|
||||
use crate::providers::traits::Provider;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -61,7 +61,7 @@ impl Provider for OllamaProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
if let Some(sys) = system_prompt {
|
||||
|
|
@ -93,9 +93,7 @@ impl Provider for OllamaProvider {
|
|||
}
|
||||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
Ok(ProviderChatResponse::with_text(
|
||||
chat_response.message.content,
|
||||
))
|
||||
Ok(chat_response.message.content)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
use crate::providers::traits::{ChatResponse, Provider};
|
||||
use crate::providers::traits::{
|
||||
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
|
||||
Provider, ToolCall as ProviderToolCall,
|
||||
};
|
||||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -22,7 +26,7 @@ struct Message {
|
|||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ApiChatResponse {
|
||||
struct ChatResponse {
|
||||
choices: Vec<Choice>,
|
||||
}
|
||||
|
||||
|
|
@ -36,6 +40,75 @@ struct ResponseMessage {
|
|||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeChatRequest {
|
||||
model: String,
|
||||
messages: Vec<NativeMessage>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<NativeToolSpec>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<NativeToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeToolSpec {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
function: NativeToolFunctionSpec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeToolFunctionSpec {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeToolCall {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<String>,
|
||||
function: NativeFunctionCall,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeFunctionCall {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChatResponse {
|
||||
choices: Vec<NativeChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChoice {
|
||||
message: NativeResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeResponseMessage {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<NativeToolCall>>,
|
||||
}
|
||||
|
||||
impl OpenAiProvider {
|
||||
pub fn new(api_key: Option<&str>) -> Self {
|
||||
Self {
|
||||
|
|
@ -47,6 +120,107 @@ impl OpenAiProvider {
|
|||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
|
||||
tools.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|tool| NativeToolSpec {
|
||||
kind: "function".to_string(),
|
||||
function: NativeToolFunctionSpec {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.parameters.clone(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[ChatMessage]) -> Vec<NativeMessage> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
if m.role == "assistant" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
|
||||
if let Some(tool_calls_value) = value.get("tool_calls") {
|
||||
if let Ok(parsed_calls) =
|
||||
serde_json::from_value::<Vec<ProviderToolCall>>(
|
||||
tool_calls_value.clone(),
|
||||
)
|
||||
{
|
||||
let tool_calls = parsed_calls
|
||||
.into_iter()
|
||||
.map(|tc| NativeToolCall {
|
||||
id: Some(tc.id),
|
||||
kind: Some("function".to_string()),
|
||||
function: NativeFunctionCall {
|
||||
name: tc.name,
|
||||
arguments: tc.arguments,
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
return NativeMessage {
|
||||
role: "assistant".to_string(),
|
||||
content,
|
||||
tool_call_id: None,
|
||||
tool_calls: Some(tool_calls),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.role == "tool" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
|
||||
let tool_call_id = value
|
||||
.get("tool_call_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
return NativeMessage {
|
||||
role: "tool".to_string(),
|
||||
content,
|
||||
tool_call_id,
|
||||
tool_calls: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
NativeMessage {
|
||||
role: m.role.clone(),
|
||||
content: Some(m.content.clone()),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
|
||||
let tool_calls = message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|tc| ProviderToolCall {
|
||||
id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
ProviderChatResponse {
|
||||
text: message.content,
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -57,7 +231,7 @@ impl Provider for OpenAiProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
|
||||
})?;
|
||||
|
|
@ -94,15 +268,60 @@ impl Provider for OpenAiProvider {
|
|||
return Err(super::api_error("OpenAI", response).await);
|
||||
}
|
||||
|
||||
let chat_response: ApiChatResponse = response.json().await?;
|
||||
let chat_response: ChatResponse = response.json().await?;
|
||||
|
||||
chat_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| ChatResponse::with_text(c.message.content))
|
||||
.map(|c| c.message.content)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ProviderChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("OpenAI API key not set. Set OPENAI_API_KEY or edit config.toml.")
|
||||
})?;
|
||||
|
||||
let tools = Self::convert_tools(request.tools);
|
||||
let native_request = NativeChatRequest {
|
||||
model: model.to_string(),
|
||||
messages: Self::convert_messages(request.messages),
|
||||
temperature,
|
||||
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
||||
tools,
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post("https://api.openai.com/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.json(&native_request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(super::api_error("OpenAI", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let message = native_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| c.message)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenAI"))?;
|
||||
Ok(Self::parse_native_response(message))
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -184,7 +403,7 @@ mod tests {
|
|||
#[test]
|
||||
fn response_deserializes_single_choice() {
|
||||
let json = r#"{"choices":[{"message":{"content":"Hi!"}}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.choices.len(), 1);
|
||||
assert_eq!(resp.choices[0].message.content, "Hi!");
|
||||
}
|
||||
|
|
@ -192,14 +411,14 @@ mod tests {
|
|||
#[test]
|
||||
fn response_deserializes_empty_choices() {
|
||||
let json = r#"{"choices":[]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert!(resp.choices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_deserializes_multiple_choices() {
|
||||
let json = r#"{"choices":[{"message":{"content":"A"}},{"message":{"content":"B"}}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.choices.len(), 2);
|
||||
assert_eq!(resp.choices[0].message.content, "A");
|
||||
}
|
||||
|
|
@ -207,7 +426,7 @@ mod tests {
|
|||
#[test]
|
||||
fn response_with_unicode() {
|
||||
let json = r#"{"choices":[{"message":{"content":"こんにちは 🦀"}}]}"#;
|
||||
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(resp.choices[0].message.content, "こんにちは 🦀");
|
||||
}
|
||||
|
||||
|
|
@ -215,7 +434,7 @@ mod tests {
|
|||
fn response_with_long_content() {
|
||||
let long = "x".repeat(100_000);
|
||||
let json = format!(r#"{{"choices":[{{"message":{{"content":"{long}"}}}}]}}"#);
|
||||
let resp: ApiChatResponse = serde_json::from_str(&json).unwrap();
|
||||
let resp: ChatResponse = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(resp.choices[0].message.content.len(), 100_000);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
use crate::providers::traits::{ChatMessage, ChatResponse, Provider};
|
||||
use crate::providers::traits::{
|
||||
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
|
||||
Provider, ToolCall as ProviderToolCall,
|
||||
};
|
||||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -36,6 +40,75 @@ struct ResponseMessage {
|
|||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeChatRequest {
|
||||
model: String,
|
||||
messages: Vec<NativeMessage>,
|
||||
temperature: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<NativeToolSpec>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<NativeToolCall>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeToolSpec {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
function: NativeToolFunctionSpec,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct NativeToolFunctionSpec {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeToolCall {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<String>,
|
||||
function: NativeFunctionCall,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct NativeFunctionCall {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChatResponse {
|
||||
choices: Vec<NativeChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeChoice {
|
||||
message: NativeResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NativeResponseMessage {
|
||||
#[serde(default)]
|
||||
content: Option<String>,
|
||||
#[serde(default)]
|
||||
tool_calls: Option<Vec<NativeToolCall>>,
|
||||
}
|
||||
|
||||
impl OpenRouterProvider {
|
||||
pub fn new(api_key: Option<&str>) -> Self {
|
||||
Self {
|
||||
|
|
@ -47,6 +120,111 @@ impl OpenRouterProvider {
|
|||
.unwrap_or_else(|_| Client::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tools(tools: Option<&[ToolSpec]>) -> Option<Vec<NativeToolSpec>> {
|
||||
let items = tools?;
|
||||
if items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
items
|
||||
.iter()
|
||||
.map(|tool| NativeToolSpec {
|
||||
kind: "function".to_string(),
|
||||
function: NativeToolFunctionSpec {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.parameters.clone(),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[ChatMessage]) -> Vec<NativeMessage> {
|
||||
messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
if m.role == "assistant" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
|
||||
if let Some(tool_calls_value) = value.get("tool_calls") {
|
||||
if let Ok(parsed_calls) =
|
||||
serde_json::from_value::<Vec<ProviderToolCall>>(
|
||||
tool_calls_value.clone(),
|
||||
)
|
||||
{
|
||||
let tool_calls = parsed_calls
|
||||
.into_iter()
|
||||
.map(|tc| NativeToolCall {
|
||||
id: Some(tc.id),
|
||||
kind: Some("function".to_string()),
|
||||
function: NativeFunctionCall {
|
||||
name: tc.name,
|
||||
arguments: tc.arguments,
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
return NativeMessage {
|
||||
role: "assistant".to_string(),
|
||||
content,
|
||||
tool_call_id: None,
|
||||
tool_calls: Some(tool_calls),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.role == "tool" {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&m.content) {
|
||||
let tool_call_id = value
|
||||
.get("tool_call_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
return NativeMessage {
|
||||
role: "tool".to_string(),
|
||||
content,
|
||||
tool_call_id,
|
||||
tool_calls: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
NativeMessage {
|
||||
role: m.role.clone(),
|
||||
content: Some(m.content.clone()),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_native_response(message: NativeResponseMessage) -> ProviderChatResponse {
|
||||
let tool_calls = message
|
||||
.tool_calls
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|tc| ProviderToolCall {
|
||||
id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
ProviderChatResponse {
|
||||
text: message.content,
|
||||
tool_calls,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
|
@ -71,7 +249,7 @@ impl Provider for OpenRouterProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."))?;
|
||||
|
||||
|
|
@ -118,7 +296,7 @@ impl Provider for OpenRouterProvider {
|
|||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| ChatResponse::with_text(c.message.content))
|
||||
.map(|c| c.message.content)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))
|
||||
}
|
||||
|
||||
|
|
@ -127,7 +305,7 @@ impl Provider for OpenRouterProvider {
|
|||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let api_key = self.api_key.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."))?;
|
||||
|
||||
|
|
@ -168,9 +346,59 @@ impl Provider for OpenRouterProvider {
|
|||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| ChatResponse::with_text(c.message.content))
|
||||
.map(|c| c.message.content)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ProviderChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ProviderChatResponse> {
|
||||
let api_key = self.api_key.as_ref().ok_or_else(|| anyhow::anyhow!(
|
||||
"OpenRouter API key not set. Run `zeroclaw onboard` or set OPENROUTER_API_KEY env var."
|
||||
))?;
|
||||
|
||||
let tools = Self::convert_tools(request.tools);
|
||||
let native_request = NativeChatRequest {
|
||||
model: model.to_string(),
|
||||
messages: Self::convert_messages(request.messages),
|
||||
temperature,
|
||||
tool_choice: tools.as_ref().map(|_| "auto".to_string()),
|
||||
tools,
|
||||
};
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post("https://openrouter.ai/api/v1/chat/completions")
|
||||
.header("Authorization", format!("Bearer {api_key}"))
|
||||
.header(
|
||||
"HTTP-Referer",
|
||||
"https://github.com/theonlyhennygod/zeroclaw",
|
||||
)
|
||||
.header("X-Title", "ZeroClaw")
|
||||
.json(&native_request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(super::api_error("OpenRouter", response).await);
|
||||
}
|
||||
|
||||
let native_response: NativeChatResponse = response.json().await?;
|
||||
let message = native_response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|c| c.message)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from OpenRouter"))?;
|
||||
Ok(Self::parse_native_response(message))
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use super::traits::{ChatMessage, ChatResponse};
|
||||
use super::traits::ChatMessage;
|
||||
use super::Provider;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -156,7 +156,7 @@ impl Provider for ReliableProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
|
||||
|
|
@ -254,7 +254,7 @@ impl Provider for ReliableProvider {
|
|||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let models = self.model_chain(model);
|
||||
let mut failures = Vec::new();
|
||||
|
||||
|
|
@ -359,12 +359,12 @@ mod tests {
|
|||
_message: &str,
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if attempt <= self.fail_until_attempt {
|
||||
anyhow::bail!(self.error);
|
||||
}
|
||||
Ok(ChatResponse::with_text(self.response))
|
||||
Ok(self.response.to_string())
|
||||
}
|
||||
|
||||
async fn chat_with_history(
|
||||
|
|
@ -372,12 +372,12 @@ mod tests {
|
|||
_messages: &[ChatMessage],
|
||||
_model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if attempt <= self.fail_until_attempt {
|
||||
anyhow::bail!(self.error);
|
||||
}
|
||||
Ok(ChatResponse::with_text(self.response))
|
||||
Ok(self.response.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -397,13 +397,13 @@ mod tests {
|
|||
_message: &str,
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
self.models_seen.lock().unwrap().push(model.to_string());
|
||||
if self.fail_models.contains(&model) {
|
||||
anyhow::bail!("500 model {} unavailable", model);
|
||||
}
|
||||
Ok(ChatResponse::with_text(self.response))
|
||||
Ok(self.response.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -426,8 +426,8 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
|
||||
let result = provider.chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "ok");
|
||||
let result = provider.simple_chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result, "ok");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
|
|
@ -448,8 +448,8 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
|
||||
let result = provider.chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "recovered");
|
||||
let result = provider.simple_chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result, "recovered");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
|
|
@ -483,8 +483,8 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
|
||||
let result = provider.chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "from fallback");
|
||||
let result = provider.simple_chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result, "from fallback");
|
||||
assert_eq!(primary_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
|
@ -517,7 +517,7 @@ mod tests {
|
|||
);
|
||||
|
||||
let err = provider
|
||||
.chat("hello", "test", 0.0)
|
||||
.simple_chat("hello", "test", 0.0)
|
||||
.await
|
||||
.expect_err("all providers should fail");
|
||||
let msg = err.to_string();
|
||||
|
|
@ -572,8 +572,8 @@ mod tests {
|
|||
1,
|
||||
);
|
||||
|
||||
let result = provider.chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "from fallback");
|
||||
let result = provider.simple_chat("hello", "test", 0.0).await.unwrap();
|
||||
assert_eq!(result, "from fallback");
|
||||
// Primary should have been called only once (no retries)
|
||||
assert_eq!(primary_calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
|
||||
|
|
@ -601,7 +601,7 @@ mod tests {
|
|||
.chat_with_history(&messages, "test", 0.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "history ok");
|
||||
assert_eq!(result, "history ok");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
|
|
@ -640,7 +640,7 @@ mod tests {
|
|||
.chat_with_history(&messages, "test", 0.0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "fallback ok");
|
||||
assert_eq!(result, "fallback ok");
|
||||
assert_eq!(primary_calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(fallback_calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
|
@ -827,7 +827,7 @@ mod tests {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
self.as_ref()
|
||||
.chat_with_system(system_prompt, message, model, temperature)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use super::traits::{ChatMessage, ChatResponse};
|
||||
use super::traits::{ChatMessage, ChatRequest, ChatResponse};
|
||||
use super::Provider;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -98,7 +98,7 @@ impl Provider for RouterProvider {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let (provider_idx, resolved_model) = self.resolve(model);
|
||||
|
||||
let (provider_name, provider) = &self.providers[provider_idx];
|
||||
|
|
@ -118,7 +118,7 @@ impl Provider for RouterProvider {
|
|||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let (provider_idx, resolved_model) = self.resolve(model);
|
||||
let (_, provider) = &self.providers[provider_idx];
|
||||
provider
|
||||
|
|
@ -126,6 +126,24 @@ impl Provider for RouterProvider {
|
|||
.await
|
||||
}
|
||||
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let (provider_idx, resolved_model) = self.resolve(model);
|
||||
let (_, provider) = &self.providers[provider_idx];
|
||||
provider.chat(request, &resolved_model, temperature).await
|
||||
}
|
||||
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
self.providers
|
||||
.get(self.default_index)
|
||||
.map(|(_, p)| p.supports_native_tools())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn warmup(&self) -> anyhow::Result<()> {
|
||||
for (name, provider) in &self.providers {
|
||||
tracing::info!(provider = name, "Warming up routed provider");
|
||||
|
|
@ -175,10 +193,10 @@ mod tests {
|
|||
_message: &str,
|
||||
model: &str,
|
||||
_temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
*self.last_model.lock().unwrap() = model.to_string();
|
||||
Ok(ChatResponse::with_text(self.response))
|
||||
Ok(self.response.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +247,7 @@ mod tests {
|
|||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
self.as_ref()
|
||||
.chat_with_system(system_prompt, message, model, temperature)
|
||||
.await
|
||||
|
|
@ -246,8 +264,11 @@ mod tests {
|
|||
],
|
||||
);
|
||||
|
||||
let result = router.chat("hello", "hint:reasoning", 0.5).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "smart-response");
|
||||
let result = router
|
||||
.simple_chat("hello", "hint:reasoning", 0.5)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "smart-response");
|
||||
assert_eq!(mocks[1].call_count(), 1);
|
||||
assert_eq!(mocks[1].last_model(), "claude-opus");
|
||||
assert_eq!(mocks[0].call_count(), 0);
|
||||
|
|
@ -260,8 +281,8 @@ mod tests {
|
|||
vec![("fast", "fast", "llama-3-70b")],
|
||||
);
|
||||
|
||||
let result = router.chat("hello", "hint:fast", 0.5).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "fast-response");
|
||||
let result = router.simple_chat("hello", "hint:fast", 0.5).await.unwrap();
|
||||
assert_eq!(result, "fast-response");
|
||||
assert_eq!(mocks[0].call_count(), 1);
|
||||
assert_eq!(mocks[0].last_model(), "llama-3-70b");
|
||||
}
|
||||
|
|
@ -273,8 +294,11 @@ mod tests {
|
|||
vec![],
|
||||
);
|
||||
|
||||
let result = router.chat("hello", "hint:nonexistent", 0.5).await.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "default-response");
|
||||
let result = router
|
||||
.simple_chat("hello", "hint:nonexistent", 0.5)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, "default-response");
|
||||
assert_eq!(mocks[0].call_count(), 1);
|
||||
// Falls back to default with the hint as model name
|
||||
assert_eq!(mocks[0].last_model(), "hint:nonexistent");
|
||||
|
|
@ -291,10 +315,10 @@ mod tests {
|
|||
);
|
||||
|
||||
let result = router
|
||||
.chat("hello", "anthropic/claude-sonnet-4-20250514", 0.5)
|
||||
.simple_chat("hello", "anthropic/claude-sonnet-4-20250514", 0.5)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "primary-response");
|
||||
assert_eq!(result, "primary-response");
|
||||
assert_eq!(mocks[0].call_count(), 1);
|
||||
assert_eq!(mocks[0].last_model(), "anthropic/claude-sonnet-4-20250514");
|
||||
}
|
||||
|
|
@ -355,7 +379,7 @@ mod tests {
|
|||
.chat_with_system(Some("system"), "hello", "model", 0.5)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.text_or_empty(), "response");
|
||||
assert_eq!(result, "response");
|
||||
assert_eq!(mock.call_count(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use crate::tools::ToolSpec;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
|
@ -29,6 +30,13 @@ impl ChatMessage {
|
|||
content: content.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: "tool".into(),
|
||||
content: content.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool call requested by the LLM.
|
||||
|
|
@ -49,14 +57,6 @@ pub struct ChatResponse {
|
|||
}
|
||||
|
||||
impl ChatResponse {
|
||||
/// Convenience: construct a plain text response with no tool calls.
|
||||
pub fn with_text(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
text: Some(text.into()),
|
||||
tool_calls: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the LLM wants to invoke at least one tool.
|
||||
pub fn has_tool_calls(&self) -> bool {
|
||||
!self.tool_calls.is_empty()
|
||||
|
|
@ -68,6 +68,13 @@ impl ChatResponse {
|
|||
}
|
||||
}
|
||||
|
||||
/// Request payload for provider chat calls.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ChatRequest<'a> {
|
||||
pub messages: &'a [ChatMessage],
|
||||
pub tools: Option<&'a [ToolSpec]>,
|
||||
}
|
||||
|
||||
/// A tool result to feed back to the LLM.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolResultMessage {
|
||||
|
|
@ -77,7 +84,7 @@ pub struct ToolResultMessage {
|
|||
|
||||
/// A message in a multi-turn conversation, including tool interactions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
pub enum ConversationMessage {
|
||||
/// Regular chat message (system, user, assistant).
|
||||
Chat(ChatMessage),
|
||||
|
|
@ -86,29 +93,34 @@ pub enum ConversationMessage {
|
|||
text: Option<String>,
|
||||
tool_calls: Vec<ToolCall>,
|
||||
},
|
||||
/// Result of a tool execution, fed back to the LLM.
|
||||
ToolResult(ToolResultMessage),
|
||||
/// Results of tool executions, fed back to the LLM.
|
||||
ToolResults(Vec<ToolResultMessage>),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait Provider: Send + Sync {
|
||||
async fn chat(
|
||||
/// Simple one-shot chat (single user message, no explicit system prompt).
|
||||
///
|
||||
/// This is the preferred API for non-agentic direct interactions.
|
||||
async fn simple_chat(
|
||||
&self,
|
||||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
self.chat_with_system(None, message, model, temperature)
|
||||
.await
|
||||
) -> anyhow::Result<String> {
|
||||
self.chat_with_system(None, message, model, temperature).await
|
||||
}
|
||||
|
||||
/// One-shot chat with optional system prompt.
|
||||
///
|
||||
/// Kept for compatibility and advanced one-shot prompting.
|
||||
async fn chat_with_system(
|
||||
&self,
|
||||
system_prompt: Option<&str>,
|
||||
message: &str,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse>;
|
||||
) -> anyhow::Result<String>;
|
||||
|
||||
/// Multi-turn conversation. Default implementation extracts the last user
|
||||
/// message and delegates to `chat_with_system`.
|
||||
|
|
@ -117,7 +129,7 @@ pub trait Provider: Send + Sync {
|
|||
messages: &[ChatMessage],
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
) -> anyhow::Result<String> {
|
||||
let system = messages
|
||||
.iter()
|
||||
.find(|m| m.role == "system")
|
||||
|
|
@ -131,6 +143,27 @@ pub trait Provider: Send + Sync {
|
|||
.await
|
||||
}
|
||||
|
||||
/// Structured chat API for agent loop callers.
|
||||
async fn chat(
|
||||
&self,
|
||||
request: ChatRequest<'_>,
|
||||
model: &str,
|
||||
temperature: f64,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let text = self
|
||||
.chat_with_history(request.messages, model, temperature)
|
||||
.await?;
|
||||
Ok(ChatResponse {
|
||||
text: Some(text),
|
||||
tool_calls: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether provider supports native tool calls over API.
|
||||
fn supports_native_tools(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Warm up the HTTP connection pool (TLS handshake, DNS, HTTP/2 setup).
|
||||
/// Default implementation is a no-op; providers with HTTP clients should override.
|
||||
async fn warmup(&self) -> anyhow::Result<()> {
|
||||
|
|
@ -153,6 +186,9 @@ mod tests {
|
|||
|
||||
let asst = ChatMessage::assistant("Hi there");
|
||||
assert_eq!(asst.role, "assistant");
|
||||
|
||||
let tool = ChatMessage::tool("{}");
|
||||
assert_eq!(tool.role, "tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -194,11 +230,11 @@ mod tests {
|
|||
let json = serde_json::to_string(&chat).unwrap();
|
||||
assert!(json.contains("\"type\":\"Chat\""));
|
||||
|
||||
let tool_result = ConversationMessage::ToolResult(ToolResultMessage {
|
||||
let tool_result = ConversationMessage::ToolResults(vec![ToolResultMessage {
|
||||
tool_call_id: "1".into(),
|
||||
content: "done".into(),
|
||||
});
|
||||
}]);
|
||||
let json = serde_json::to_string(&tool_result).unwrap();
|
||||
assert!(json.contains("\"type\":\"ToolResult\""));
|
||||
assert!(json.contains("\"type\":\"ToolResults\""));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue