use std::error::Error;
use async_trait::async_trait;
use serde_json::Value;
use thiserror::Error;
use crate::lifecycle::{LifecycleEmitter, LifecycleObserver, ModelResponseType};
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>;
async fn complete_with_optional_metadata(
&self,
request: ModelRequest,
) -> Result<(ModelResponse, Option<CompletionMetadata>), ModelError> {
self.complete(request)
.await
.map(|response| (response, None))
}
}
#[async_trait]
pub trait ModelWithMetadata: Send + Sync {
async fn complete_with_metadata(
&self,
request: ModelRequest,
) -> Result<ModelCompletion, ModelError>;
}
#[async_trait]
impl<ModelType> Model for ModelType
where
ModelType: ModelWithMetadata + ?Sized,
{
async fn complete(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
self.complete_with_metadata(request)
.await
.map(ModelCompletion::into_response)
}
async fn complete_with_optional_metadata(
&self,
request: ModelRequest,
) -> Result<(ModelResponse, Option<CompletionMetadata>), ModelError> {
let completion = self.complete_with_metadata(request).await?;
let ModelCompletion { metadata, response } = completion;
Ok((response, Some(metadata)))
}
}
pub struct ModelClient {
backend: chat_completion::ChatCompletionBackend,
lifecycle: LifecycleEmitter,
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
}
#[must_use]
pub fn with_lifecycle_observer(mut self, observer: impl LifecycleObserver + 'static) -> Self {
self.lifecycle = LifecycleEmitter::new(observer);
self
}
pub async fn complete(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
self.complete_with_metadata(request)
.await
.map(ModelCompletion::into_response)
}
pub async fn complete_with_metadata(
&self,
request: ModelRequest,
) -> Result<ModelCompletion, ModelError> {
let _duration = telemetry::RequestDuration::start(self.metadata());
let lifecycle = if request.lifecycle_observed() {
None
} else {
self.lifecycle
.start_model_request(Some(self.metadata.clone()), 0, None)
};
let result = async {
let response = self.backend.generate(&request).await?;
match response {
chat_completion::GeneratedResponse::Output { metadata, output } => request
.schema()
.parse_and_validate(&output)
.map(ModelResponse::from_output)
.map(|response| ModelCompletion::new(metadata, response))
.map_err(ModelError::from),
chat_completion::GeneratedResponse::ToolCall { call, metadata } => Ok(
ModelCompletion::new(metadata, ModelResponse::tool_call(call)),
),
}
}
.await;
if let Some(lifecycle) = lifecycle {
match &result {
Ok(completion) => lifecycle.completed(
Some(completion.metadata.clone()),
completion.response.response_type(),
),
Err(error) => lifecycle.failed(error.error_type()),
}
}
result
}
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,
lifecycle: LifecycleEmitter::default(),
metadata,
})
}
}
#[async_trait]
impl ModelWithMetadata for ModelClient {
async fn complete_with_metadata(
&self,
request: ModelRequest,
) -> Result<ModelCompletion, ModelError> {
ModelClient::complete_with_metadata(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 {
lifecycle_observed: bool,
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 {
lifecycle_observed: false,
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 lifecycle_observed(&self) -> bool {
self.lifecycle_observed
}
pub(crate) fn mark_lifecycle_observed(&mut self) {
self.lifecycle_observed = true;
}
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 struct ModelCompletion {
metadata: CompletionMetadata,
response: ModelResponse,
}
impl ModelCompletion {
pub fn new(metadata: CompletionMetadata, response: ModelResponse) -> Self {
Self { metadata, response }
}
pub fn metadata(&self) -> &CompletionMetadata {
&self.metadata
}
pub fn response(&self) -> &ModelResponse {
&self.response
}
pub fn into_response(self) -> ModelResponse {
self.response
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CompletionMetadata {
finish_reason: String,
response_id: Option<String>,
response_model: Option<String>,
system_fingerprint: Option<String>,
usage: Option<CompletionUsage>,
}
impl CompletionMetadata {
pub fn new(
finish_reason: String,
response_id: Option<String>,
response_model: Option<String>,
system_fingerprint: Option<String>,
usage: Option<CompletionUsage>,
) -> Self {
Self {
finish_reason,
response_id,
response_model,
system_fingerprint,
usage,
}
}
pub fn finish_reason(&self) -> &str {
&self.finish_reason
}
pub fn response_id(&self) -> Option<&str> {
self.response_id.as_deref()
}
pub fn response_model(&self) -> Option<&str> {
self.response_model.as_deref()
}
pub fn system_fingerprint(&self) -> Option<&str> {
self.system_fingerprint.as_deref()
}
pub fn usage(&self) -> Option<&CompletionUsage> {
self.usage.as_ref()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CompletionUsage {
cache_hit: Option<u64>,
cache_miss: Option<u64>,
input: Option<u64>,
output: Option<u64>,
reasoning: Option<u64>,
total: Option<u64>,
}
impl CompletionUsage {
pub fn new(
cache_hit_tokens: Option<u64>,
cache_miss_tokens: Option<u64>,
input_tokens: Option<u64>,
output_tokens: Option<u64>,
reasoning_tokens: Option<u64>,
total_tokens: Option<u64>,
) -> Self {
Self {
cache_hit: cache_hit_tokens,
cache_miss: cache_miss_tokens,
input: input_tokens,
output: output_tokens,
reasoning: reasoning_tokens,
total: total_tokens,
}
}
pub fn cache_hit_tokens(self) -> Option<u64> {
self.cache_hit
}
pub fn cache_miss_tokens(self) -> Option<u64> {
self.cache_miss
}
pub fn input_tokens(self) -> Option<u64> {
self.input
}
pub fn output_tokens(self) -> Option<u64> {
self.output
}
pub fn reasoning_tokens(self) -> Option<u64> {
self.reasoning
}
pub fn total_tokens(self) -> Option<u64> {
self.total
}
}
#[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)
}
pub(crate) fn response_type(&self) -> ModelResponseType {
match self {
Self::Output(_) => ModelResponseType::Output,
Self::ToolCall(_) => ModelResponseType::ToolCall,
}
}
}
#[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,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ModelErrorType {
Request,
Transport,
Provider,
InvalidProviderResponse,
InvalidResponse,
UnsupportedOutput,
ResponseTooLarge,
InvalidOutput,
InvalidToolCall,
}
impl ModelErrorType {
pub fn as_str(self) -> &'static str {
match self {
Self::Request => "request_error",
Self::Transport => "transport_error",
Self::Provider => "provider_error",
Self::InvalidProviderResponse => "invalid_provider_response",
Self::InvalidResponse => "invalid_response",
Self::UnsupportedOutput => "unsupported_output",
Self::ResponseTooLarge => "response_too_large",
Self::InvalidOutput => "invalid_output",
Self::InvalidToolCall => "invalid_tool_call",
}
}
}
#[derive(Debug, Error)]
#[error("{source}")]
struct ClassifiedRequestError {
error_type: ModelErrorType,
#[source]
source: Box<dyn Error + Send + Sync>,
}
#[derive(Debug, Error)]
#[error("{provider} returned HTTP {status}: {body}")]
struct ProviderRequestError {
body: String,
provider: &'static str,
#[source]
source: reqwest::Error,
status: reqwest::StatusCode,
}
impl ModelError {
pub fn request(error: impl Error + Send + Sync + 'static) -> Self {
Self::Request(Box::new(error))
}
pub fn error_type(&self) -> ModelErrorType {
match self {
Self::Request(source) => {
if source.downcast_ref::<ProviderRequestError>().is_some() {
ModelErrorType::Provider
} else {
source
.downcast_ref::<ClassifiedRequestError>()
.map_or(ModelErrorType::Request, |error| error.error_type)
}
}
Self::InvalidResponse | Self::IncompleteResponse { .. } => {
ModelErrorType::InvalidResponse
}
Self::ResponseBodyTooLarge | Self::ResponseContentTooLarge => {
ModelErrorType::ResponseTooLarge
}
Self::UnsupportedOutputSchema { .. } => ModelErrorType::UnsupportedOutput,
Self::InvalidJson { .. } | Self::SchemaViolation { .. } => {
ModelErrorType::InvalidOutput
}
Self::MissingToolCall
| Self::MultipleToolCalls
| Self::ToolCallWithContent
| Self::TerminalResponseWithToolCalls
| Self::UnsupportedToolType { .. }
| Self::UnsupportedToolName { .. }
| Self::InvalidToolArguments { .. } => ModelErrorType::InvalidToolCall,
}
}
pub fn http_status(&self) -> Option<u16> {
match self {
Self::Request(source) => source
.downcast_ref::<ProviderRequestError>()
.map(|error| error.status.as_u16()),
_ => None,
}
}
pub(crate) fn provider_request(
provider: &'static str,
body: String,
source: reqwest::Error,
status: reqwest::StatusCode,
) -> Self {
Self::Request(Box::new(ProviderRequestError {
body,
provider,
source,
status,
}))
}
pub(crate) fn classified_request(
error_type: ModelErrorType,
source: Box<dyn Error + Send + Sync>,
) -> Self {
Self::Request(Box::new(ClassifiedRequestError { error_type, source }))
}
}
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 std::sync::{Arc, Mutex};
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::tool::{ReadArguments, ToolCall};
struct ResponseOnlyModel;
#[async_trait]
impl Model for ResponseOnlyModel {
async fn complete(&self, _request: ModelRequest) -> Result<ModelResponse, ModelError> {
Ok(ModelResponse::Output(json!({ "name": "Ada" })))
}
}
struct MetadataModel;
#[async_trait]
impl ModelWithMetadata for MetadataModel {
async fn complete_with_metadata(
&self,
_request: ModelRequest,
) -> Result<ModelCompletion, ModelError> {
Ok(ModelCompletion::new(
CompletionMetadata::new(
"stop".to_string(),
Some("response-id".to_string()),
None,
None,
None,
),
ModelResponse::Output(json!({ "name": "Ada" })),
))
}
}
fn test_request() -> ModelRequest {
let schema =
OutputSchema::new(json!({ "type": "object" })).expect("fixture schema should be valid");
ModelRequest::new("prompt", schema)
}
#[tokio::test]
async fn response_only_model_defaults_optional_metadata_to_none() {
let model = ResponseOnlyModel;
let (response, metadata) = model
.complete_with_optional_metadata(test_request())
.await
.expect("response-only model should complete");
assert_eq!(response.output(), Some(&json!({ "name": "Ada" })));
assert!(metadata.is_none());
}
#[tokio::test]
async fn metadata_model_automatically_implements_model_paths() {
let model = MetadataModel;
let response = Model::complete(&model, test_request())
.await
.expect("metadata model should complete through Model");
let (optional_response, metadata) =
Model::complete_with_optional_metadata(&model, test_request())
.await
.expect("metadata model should expose optional metadata");
assert_eq!(response.output(), Some(&json!({ "name": "Ada" })));
assert_eq!(optional_response.output(), Some(&json!({ "name": "Ada" })));
assert_eq!(
metadata.as_ref().and_then(CompletionMetadata::response_id),
Some("response-id")
);
}
#[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_observes_success_unless_request_is_already_observed() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"choices": [{
"finish_reason": "stop",
"message": {"content": r#"{"name":"Ada"}"#}
}]
})))
.expect(2)
.mount(&server)
.await;
let events = Arc::new(Mutex::new(Vec::new()));
let observed_events = Arc::clone(&events);
let client = ModelClient::qwen(QwenConfig {
api_key: "test-key".to_string(),
base_url: server.uri(),
model: "qwen-plus".to_string(),
})
.expect("fixture configuration should be valid")
.with_lifecycle_observer(move |event| {
observed_events
.lock()
.expect("event recorder should not be poisoned")
.push(event);
});
let schema = OutputSchema::new(json!({
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"]
}))
.expect("fixture schema should be valid");
let mut observed_request = ModelRequest::new("prompt", schema.clone());
observed_request.mark_lifecycle_observed();
client
.complete(ModelRequest::new("prompt", schema))
.await
.expect("request should succeed");
client
.complete(observed_request)
.await
.expect("externally observed request should succeed");
let events = events
.lock()
.expect("event recorder should not be poisoned");
assert_eq!(events.len(), 2);
assert!(matches!(
events[0].kind(),
crate::LifecycleEventKind::ModelRequestStarted { .. }
));
assert!(matches!(
events[1].kind(),
crate::LifecycleEventKind::ModelRequestCompleted { .. }
));
}
#[tokio::test]
async fn client_observes_classified_failure() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/chat/completions"))
.respond_with(ResponseTemplate::new(503).set_body_string("offline"))
.expect(1)
.mount(&server)
.await;
let events = Arc::new(Mutex::new(Vec::new()));
let observed_events = Arc::clone(&events);
let client = ModelClient::qwen(QwenConfig {
api_key: "test-key".to_string(),
base_url: server.uri(),
model: "qwen-plus".to_string(),
})
.expect("fixture configuration should be valid")
.with_lifecycle_observer(move |event| {
observed_events
.lock()
.expect("event recorder should not be poisoned")
.push(event);
});
let schema =
OutputSchema::new(json!({ "type": "object" })).expect("fixture schema should be valid");
let error = client
.complete(ModelRequest::new("prompt", schema))
.await
.expect_err("provider failure should be returned");
assert_eq!(error.error_type(), ModelErrorType::Provider);
let events = events
.lock()
.expect("event recorder should not be poisoned");
assert_eq!(events.len(), 2);
assert!(matches!(
events[1].kind(),
crate::LifecycleEventKind::ModelRequestFailed {
error_type: ModelErrorType::Provider,
..
}
));
}
#[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 completion_exposes_normalized_metadata_and_response() {
let usage = CompletionUsage::new(Some(5), Some(8), Some(13), Some(21), Some(3), Some(34));
let metadata = CompletionMetadata::new(
"stop".to_string(),
Some("response-1".to_string()),
Some("provider-model".to_string()),
Some("fingerprint-1".to_string()),
Some(usage),
);
let response = ModelResponse::from_output(json!({ "name": "Ada" }));
let completion = ModelCompletion::new(metadata, response.clone());
let completion_metadata = completion.metadata();
let completion_response = completion.response();
assert_eq!(completion_metadata.finish_reason(), "stop");
assert_eq!(completion_metadata.response_id(), Some("response-1"));
assert_eq!(completion_metadata.response_model(), Some("provider-model"));
assert_eq!(
completion_metadata.system_fingerprint(),
Some("fingerprint-1")
);
assert_eq!(completion_metadata.usage(), Some(&usage));
assert_eq!(usage.cache_hit_tokens(), Some(5));
assert_eq!(usage.cache_miss_tokens(), Some(8));
assert_eq!(usage.input_tokens(), Some(13));
assert_eq!(usage.output_tokens(), Some(21));
assert_eq!(usage.reasoning_tokens(), Some(3));
assert_eq!(usage.total_tokens(), Some(34));
assert_eq!(completion_response, &response);
assert_eq!(completion.into_response(), response);
}
#[test]
fn completion_metadata_preserves_absent_provider_fields() {
let metadata = CompletionMetadata::new("stop".to_string(), None, None, None, None);
assert_eq!(metadata.response_id(), None);
assert_eq!(metadata.response_model(), None);
assert_eq!(metadata.system_fingerprint(), None);
assert_eq!(metadata.usage(), 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 classifies_model_errors_with_stable_telemetry_values() {
let errors = [
(
ModelError::request(io::Error::other("request")),
ModelErrorType::Request,
),
(ModelError::InvalidResponse, ModelErrorType::InvalidResponse),
(
ModelError::IncompleteResponse {
reason: "length".to_string(),
},
ModelErrorType::InvalidResponse,
),
(
ModelError::ResponseBodyTooLarge,
ModelErrorType::ResponseTooLarge,
),
(
ModelError::ResponseContentTooLarge,
ModelErrorType::ResponseTooLarge,
),
(
ModelError::UnsupportedOutputSchema {
reason: "object required".to_string(),
},
ModelErrorType::UnsupportedOutput,
),
(
ModelError::InvalidJson {
reason: "invalid".to_string(),
},
ModelErrorType::InvalidOutput,
),
(
ModelError::SchemaViolation {
path: "$".to_string(),
reason: "invalid".to_string(),
},
ModelErrorType::InvalidOutput,
),
(ModelError::MissingToolCall, ModelErrorType::InvalidToolCall),
(
ModelError::MultipleToolCalls,
ModelErrorType::InvalidToolCall,
),
(
ModelError::ToolCallWithContent,
ModelErrorType::InvalidToolCall,
),
(
ModelError::TerminalResponseWithToolCalls,
ModelErrorType::InvalidToolCall,
),
(
ModelError::UnsupportedToolType {
kind: "custom".to_string(),
},
ModelErrorType::InvalidToolCall,
),
(
ModelError::UnsupportedToolName {
name: "write".to_string(),
},
ModelErrorType::InvalidToolCall,
),
(
ModelError::InvalidToolArguments {
reason: "invalid".to_string(),
},
ModelErrorType::InvalidToolCall,
),
];
let classifications =
errors.map(|(error, expected)| (error.error_type(), expected, error.http_status()));
assert!(
classifications
.into_iter()
.all(|(actual, expected, status)| actual == expected && status.is_none())
);
assert_eq!(ModelErrorType::Request.as_str(), "request_error");
assert_eq!(ModelErrorType::Transport.as_str(), "transport_error");
assert_eq!(ModelErrorType::Provider.as_str(), "provider_error");
assert_eq!(
ModelErrorType::InvalidProviderResponse.as_str(),
"invalid_provider_response"
);
assert_eq!(ModelErrorType::InvalidResponse.as_str(), "invalid_response");
assert_eq!(
ModelErrorType::UnsupportedOutput.as_str(),
"unsupported_output"
);
assert_eq!(
ModelErrorType::ResponseTooLarge.as_str(),
"response_too_large"
);
assert_eq!(ModelErrorType::InvalidOutput.as_str(), "invalid_output");
assert_eq!(
ModelErrorType::InvalidToolCall.as_str(),
"invalid_tool_call"
);
}
#[test]
fn classified_request_retains_source_and_type() {
let error = ModelError::classified_request(
ModelErrorType::Transport,
io::Error::other("connection reset").into(),
);
let source = std::error::Error::source(&error)
.and_then(std::error::Error::source)
.expect("classified request should retain its original source");
assert_eq!(error.error_type(), ModelErrorType::Transport);
assert_eq!(error.http_status(), None);
assert_eq!(source.to_string(), "connection reset");
}
#[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"
);
}
}