use std::error::Error;
use async_trait::async_trait;
use serde_json::Value;
use thiserror::Error;
use crate::provider::{self, KimiConfig, MuseConfig, QwenConfig};
use crate::schema_contract::{OutputSchema, OutputValidationError};
use crate::{chat_completion, telemetry, tool};
#[cfg_attr(test, mockall::automock)]
#[async_trait]
pub trait Model: Send + Sync {
async fn complete(&self, request: ModelRequest) -> Result<ModelResponse, ModelError>;
}
pub struct ModelClient {
backend: chat_completion::ChatCompletionBackend,
metadata: ModelMetadata,
}
impl ModelClient {
pub fn kimi(config: KimiConfig) -> Result<Self, ModelMetadataError> {
Self::chat_completion(
config.api_key,
config.base_url,
config.model,
provider::KIMI_POLICY,
)
}
pub fn muse(config: MuseConfig) -> Result<Self, ModelMetadataError> {
Self::chat_completion(
config.api_key,
config.base_url,
config.model,
provider::MUSE_POLICY,
)
}
pub fn qwen(config: QwenConfig) -> Result<Self, ModelMetadataError> {
Self::chat_completion(
config.api_key,
config.base_url,
config.model,
provider::QWEN_POLICY,
)
}
pub fn metadata(&self) -> &ModelMetadata {
&self.metadata
}
pub async fn complete(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
let _duration = telemetry::RequestDuration::start(self.metadata());
let response = self.backend.generate(&request).await?;
match response {
chat_completion::GeneratedResponse::Output(output) => request
.schema()
.parse_and_validate(&output)
.map(ModelResponse::from_output)
.map_err(ModelError::from),
chat_completion::GeneratedResponse::ToolCall(call) => {
Ok(ModelResponse::tool_call(call))
}
}
}
fn chat_completion(
api_key: String,
base_url: String,
model: String,
policy: chat_completion::ChatCompletionProviderPolicy,
) -> Result<Self, ModelMetadataError> {
let backend = chat_completion::ChatCompletionBackend::new(api_key, base_url, model, policy);
let (provider, model) = backend.identity();
let metadata = ModelMetadata::new(provider, model)?;
Ok(Self { backend, metadata })
}
}
#[async_trait]
impl Model for ModelClient {
async fn complete(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
ModelClient::complete(self, request).await
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ModelMetadata {
model: String,
provider: &'static str,
}
impl ModelMetadata {
pub fn new(
provider: &'static str,
model: impl Into<String>,
) -> Result<Self, ModelMetadataError> {
if provider.trim().is_empty() {
return Err(ModelMetadataError::EmptyProvider);
}
let model = model.into();
if model.trim().is_empty() {
return Err(ModelMetadataError::EmptyModel);
}
Ok(Self { model, provider })
}
pub fn model(&self) -> &str {
&self.model
}
pub fn provider(&self) -> &'static str {
self.provider
}
}
#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
pub enum ModelMetadataError {
#[error("model provider must not be empty")]
EmptyProvider,
#[error("model identifier must not be empty")]
EmptyModel,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ModelRequest {
messages: Vec<ModelMessage>,
prompt: String,
schema: OutputSchema,
tools: Vec<tool::ToolDefinition>,
}
impl ModelRequest {
pub fn new(prompt: impl Into<String>, schema: OutputSchema) -> Self {
let prompt = prompt.into();
Self {
messages: vec![ModelMessage::User(prompt.clone())],
prompt,
schema,
tools: Vec::new(),
}
}
#[must_use]
pub fn with_tool(mut self, tool: tool::ToolDefinition) -> Self {
if !self.advertises_tool(tool.name()) {
self.tools.push(tool);
}
self
}
pub fn prompt(&self) -> &str {
&self.prompt
}
pub fn schema(&self) -> &OutputSchema {
&self.schema
}
pub fn tools(&self) -> &[tool::ToolDefinition] {
&self.tools
}
pub(crate) fn advertises_tool(&self, name: &str) -> bool {
self.tools.iter().any(|tool| tool.name() == name)
}
pub(crate) fn messages(&self) -> &[ModelMessage] {
&self.messages
}
pub(crate) fn record_tool_result(&mut self, call: tool::ToolCall, content: String) {
let call_id = call.id().to_string();
let name = call.name().to_string();
self.messages.push(ModelMessage::AssistantToolCall(call));
self.messages.push(ModelMessage::ToolResult {
call_id,
content,
name,
});
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum ModelMessage {
User(String),
AssistantToolCall(tool::ToolCall),
ToolResult {
call_id: String,
content: String,
name: String,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ModelResponse {
Output(Value),
ToolCall(tool::ToolCall),
}
impl ModelResponse {
pub fn output(&self) -> Option<&Value> {
match self {
Self::Output(output) => Some(output),
Self::ToolCall(_) => None,
}
}
pub fn call(&self) -> Option<&tool::ToolCall> {
match self {
Self::Output(_) => None,
Self::ToolCall(call) => Some(call),
}
}
fn from_output(output: Value) -> Self {
Self::Output(output)
}
fn tool_call(call: tool::ToolCall) -> Self {
Self::ToolCall(call)
}
}
#[derive(Debug, Error)]
pub enum ModelError {
#[error("model request failed: {0}")]
Request(#[source] Box<dyn Error + Send + Sync>),
#[error("model returned no response content")]
InvalidResponse,
#[error("model response is incomplete: {reason}")]
IncompleteResponse {
reason: String,
},
#[error("model response body exceeds the size limit")]
ResponseBodyTooLarge,
#[error("provider cannot satisfy this output schema: {reason}")]
UnsupportedOutputSchema {
reason: String,
},
#[error("model response content exceeds the size limit")]
ResponseContentTooLarge,
#[error("model returned invalid JSON: {reason}")]
InvalidJson {
reason: String,
},
#[error("model output violates the schema at {path}: {reason}")]
SchemaViolation {
path: String,
reason: String,
},
#[error("model returned no tool call")]
MissingToolCall,
#[error("model returned multiple tool calls")]
MultipleToolCalls,
#[error("model tool call response contained terminal content")]
ToolCallWithContent,
#[error("model terminal response contained tool calls")]
TerminalResponseWithToolCalls,
#[error("model requested unsupported tool type: {kind}")]
UnsupportedToolType {
kind: String,
},
#[error("model requested unsupported tool: {name}")]
UnsupportedToolName {
name: String,
},
#[error("model returned invalid tool arguments: {reason}")]
InvalidToolArguments {
reason: String,
},
}
impl ModelError {
pub fn request(error: impl Error + Send + Sync + 'static) -> Self {
Self::Request(Box::new(error))
}
}
impl From<OutputValidationError> for ModelError {
fn from(error: OutputValidationError) -> Self {
match error {
OutputValidationError::InvalidJson(reason) => Self::InvalidJson { reason },
OutputValidationError::SchemaViolation { path, reason } => {
Self::SchemaViolation { path, reason }
}
OutputValidationError::TooLarge => Self::ResponseContentTooLarge,
}
}
}
#[cfg(test)]
mod tests {
use std::io;
use serde_json::json;
use super::*;
use crate::tool::{ReadArguments, ToolCall};
#[test]
fn client_exposes_provider_and_model() {
let client = ModelClient::qwen(QwenConfig {
api_key: "test-key".to_string(),
base_url: "https://example.com".to_string(),
model: "qwen-plus".to_string(),
})
.expect("fixture configuration should be valid");
let metadata = client.metadata();
assert_eq!(metadata.provider(), "alibaba_cloud");
assert_eq!(metadata.model(), "qwen-plus");
assert_eq!(
metadata,
&ModelMetadata::new("alibaba_cloud", "qwen-plus").expect("metadata should be valid")
);
}
#[tokio::test]
async fn client_supports_dynamic_model_dispatch() {
let model: Box<dyn Model> = Box::new(
ModelClient::qwen(QwenConfig {
api_key: "test-key".to_string(),
base_url: "https://example.com".to_string(),
model: "qwen-plus".to_string(),
})
.expect("fixture configuration should be valid"),
);
let schema = OutputSchema::new(json!({ "type": "array" })).expect("schema should be valid");
let error = model
.complete(ModelRequest::new("return a list", schema))
.await
.expect_err("Qwen should reject a non-object schema");
assert!(matches!(error, ModelError::UnsupportedOutputSchema { .. }));
}
#[test]
fn metadata_rejects_empty_provider() {
let error =
ModelMetadata::new(" ", "stub-large").expect_err("empty provider should be rejected");
assert_eq!(error, ModelMetadataError::EmptyProvider);
assert_eq!(error.to_string(), "model provider must not be empty");
}
#[test]
fn metadata_rejects_empty_model() {
let error =
ModelMetadata::new("stub_provider", " ").expect_err("empty model should be rejected");
assert_eq!(error, ModelMetadataError::EmptyModel);
assert_eq!(error.to_string(), "model identifier must not be empty");
}
#[test]
fn request_contains_prompt_and_schema() {
let schema =
OutputSchema::new(json!({ "type": "object" })).expect("schema should be valid");
let request = ModelRequest::new("hello", schema.clone());
assert_eq!(request.prompt(), "hello");
assert_eq!(request.schema(), &schema);
assert!(request.tools().is_empty());
}
#[test]
fn request_explicitly_advertises_read() {
let schema =
OutputSchema::new(json!({ "type": "object" })).expect("schema should be valid");
let request = ModelRequest::new("hello", schema).with_tool(tool::ToolDefinition::read());
assert_eq!(request.tools(), &[tool::ToolDefinition::read()]);
assert!(request.advertises_tool("read"));
assert!(!request.advertises_tool("write"));
}
#[test]
fn request_deduplicates_native_tools() {
let schema =
OutputSchema::new(json!({ "type": "object" })).expect("schema should be valid");
let request = ModelRequest::new("hello", schema)
.with_tool(tool::ToolDefinition::read())
.with_tool(tool::ToolDefinition::read());
assert_eq!(request.tools(), &[tool::ToolDefinition::read()]);
}
#[test]
fn response_exposes_validated_output() {
let value = json!({ "name": "Ada" });
let response = ModelResponse::from_output(value.clone());
assert_eq!(response.output(), Some(&value));
assert!(response.call().is_none());
}
#[test]
fn response_debug_redacts_provider_reasoning() {
let secret_reasoning = "private reasoning from repository context";
let arguments = serde_json::from_value::<ReadArguments>(json!({
"path": "Cargo.toml"
}))
.expect("read arguments should be valid");
let response = ModelResponse::tool_call(ToolCall::read(
"call_read".to_string(),
arguments,
Some(secret_reasoning.to_string()),
));
let debug_output = format!("{response:?}");
assert!(debug_output.contains("call_read"));
assert!(debug_output.contains("[REDACTED]"));
assert!(!debug_output.contains(secret_reasoning));
}
#[test]
fn invalid_response_error_has_user_facing_message() {
let message = ModelError::InvalidResponse.to_string();
assert_eq!(message, "model returned no response content");
}
#[test]
fn incomplete_response_error_includes_reason() {
let message = ModelError::IncompleteResponse {
reason: "length".to_string(),
}
.to_string();
assert_eq!(message, "model response is incomplete: length");
}
#[test]
fn request_error_includes_source_message() {
let source = io::Error::other("connection refused");
let message = ModelError::request(source).to_string();
assert_eq!(message, "model request failed: connection refused");
}
#[test]
fn unsupported_schema_error_includes_reason() {
let message = ModelError::UnsupportedOutputSchema {
reason: "top-level object required".to_string(),
}
.to_string();
assert_eq!(
message,
"provider cannot satisfy this output schema: top-level object required"
);
}
#[test]
fn oversized_response_body_error_has_user_facing_message() {
let message = ModelError::ResponseBodyTooLarge.to_string();
assert_eq!(message, "model response body exceeds the size limit");
}
#[test]
fn converts_invalid_json_error() {
let error = OutputValidationError::InvalidJson("expected value".to_string());
let error = ModelError::from(error);
assert_eq!(
error.to_string(),
"model returned invalid JSON: expected value"
);
}
#[test]
fn converts_schema_violation_error() {
let error = OutputValidationError::SchemaViolation {
path: "/name".to_string(),
reason: "wrong type".to_string(),
};
let error = ModelError::from(error);
assert_eq!(
error.to_string(),
"model output violates the schema at /name: wrong type"
);
}
#[test]
fn converts_oversized_content_error() {
let error = ModelError::from(OutputValidationError::TooLarge);
assert_eq!(
error.to_string(),
"model response content exceeds the size limit"
);
}
}