use crate::conversation::{Message, ToolCall};
use crate::error::KowalskiError;
use crate::tools::{ParameterType, Tool};
use async_trait::async_trait;
use futures::stream::Stream;
use serde_json::json;
use std::pin::Pin;
pub type TokenStream<'a> = Pin<Box<dyn Stream<Item = Result<String, KowalskiError>> + Send + 'a>>;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub parameters: serde_json::Value,
}
impl ToolDefinition {
pub fn from_tool(tool: &dyn Tool) -> Self {
let mut properties = serde_json::Map::new();
let mut required: Vec<String> = Vec::new();
for param in tool.parameters() {
let type_name = match param.parameter_type {
ParameterType::String => "string",
ParameterType::Number => "number",
ParameterType::Boolean => "boolean",
ParameterType::Array => "array",
ParameterType::Object => "object",
};
let mut property = json!({ "type": type_name, "description": param.description });
if let Some(default) = param.default_value {
property["default"] = json!(default);
}
properties.insert(param.name.clone(), property);
if param.required {
required.push(param.name);
}
}
Self {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters: json!({
"type": "object",
"properties": properties,
"required": required,
}),
}
}
pub fn wire_json(&self) -> serde_json::Value {
json!({
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
}
})
}
}
#[derive(Debug, Clone)]
pub enum ChatOutcome {
Text(String),
ToolCalls {
content: Option<String>,
calls: Vec<ToolCall>,
},
}
#[async_trait]
pub trait LLMProvider: Send + Sync {
async fn chat(&self, model: &str, messages: &[Message]) -> Result<String, KowalskiError>;
async fn embed(&self, text: &str) -> Result<Vec<f32>, KowalskiError>;
fn supports_streaming(&self) -> bool;
fn supports_native_tools(&self, _model: &str) -> bool {
false
}
async fn chat_with_tool_defs(
&self,
model: &str,
messages: &[Message],
tools: &[ToolDefinition],
) -> Result<ChatOutcome, KowalskiError> {
let _ = tools;
self.chat(model, messages).await.map(ChatOutcome::Text)
}
fn supports_structured_output(&self, _model: &str) -> bool {
false
}
async fn chat_with_schema(
&self,
model: &str,
messages: &[Message],
schema: &serde_json::Value,
) -> Result<String, KowalskiError> {
let _ = (messages, schema);
Err(KowalskiError::Server(format!(
"This LLM provider does not support constrained structured output (model `{}`). \
Callers should check supports_structured_output() and fall back to prompt-based \
JSON extraction.",
model
)))
}
fn chat_stream(&self, model: &str, messages: Vec<Message>) -> TokenStream<'_>;
}
pub fn schema_wire_name(schema: &serde_json::Value) -> String {
let name: String = schema["title"]
.as_str()
.unwrap_or("structured_output")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.take(64)
.collect();
if name.is_empty() {
"structured_output".to_string()
} else {
name
}
}
pub fn chat_stream_single_chunk<'a>(
llm: &'a (dyn LLMProvider + 'a),
model: &'a str,
messages: Vec<Message>,
) -> TokenStream<'a> {
Box::pin(async_stream::stream! {
match llm.chat(model, &messages).await {
Ok(t) if !t.is_empty() => yield Ok(t),
Ok(_) => {}
Err(e) => yield Err(e),
}
})
}
#[cfg(test)]
mod tests {
use super::*;
struct MinimalProvider;
#[async_trait]
impl LLMProvider for MinimalProvider {
async fn chat(&self, _model: &str, _messages: &[Message]) -> Result<String, KowalskiError> {
Ok("hi".to_string())
}
async fn embed(&self, _text: &str) -> Result<Vec<f32>, KowalskiError> {
Ok(vec![])
}
fn supports_streaming(&self) -> bool {
false
}
fn chat_stream(&self, _model: &str, _messages: Vec<Message>) -> TokenStream<'_> {
Box::pin(futures::stream::empty())
}
}
#[tokio::test]
async fn default_structured_output_degrades_gracefully() {
let p = MinimalProvider;
assert!(!p.supports_structured_output("any-model"));
let err = p
.chat_with_schema("any-model", &[], &json!({"type": "object"}))
.await
.unwrap_err();
assert!(
err.to_string().contains("does not support constrained structured output"),
"err: {err}"
);
}
#[test]
fn schema_wire_name_sanitizes_title() {
assert_eq!(schema_wire_name(&json!({"title": "DeltaBatch"})), "DeltaBatch");
assert_eq!(
schema_wire_name(&json!({"title": "My Schema (v1)!"})),
"My_Schema__v1__"
);
assert_eq!(schema_wire_name(&json!({})), "structured_output");
assert_eq!(schema_wire_name(&json!({"title": ""})), "structured_output");
let long = "x".repeat(100);
assert_eq!(schema_wire_name(&json!({ "title": long })).len(), 64);
}
}