1use std::error::Error;
2
3use async_trait::async_trait;
4use serde_json::Value;
5use thiserror::Error;
6
7use crate::provider::{self, KimiConfig, MuseConfig, QwenConfig};
8use crate::schema_contract::{OutputSchema, OutputValidationError};
9use crate::{chat_completion, telemetry, tool};
10
11#[cfg_attr(test, mockall::automock)]
16#[async_trait]
17pub trait Model: Send + Sync {
18 async fn complete(&self, request: ModelRequest) -> Result<ModelResponse, ModelError>;
25}
26
27pub struct ModelClient {
33 backend: chat_completion::ChatCompletionBackend,
34 metadata: ModelMetadata,
35}
36
37impl ModelClient {
38 pub fn kimi(config: KimiConfig) -> Result<Self, ModelMetadataError> {
45 Self::chat_completion(
46 config.api_key,
47 config.base_url,
48 config.model,
49 provider::KIMI_POLICY,
50 )
51 }
52
53 pub fn muse(config: MuseConfig) -> Result<Self, ModelMetadataError> {
60 Self::chat_completion(
61 config.api_key,
62 config.base_url,
63 config.model,
64 provider::MUSE_POLICY,
65 )
66 }
67
68 pub fn qwen(config: QwenConfig) -> Result<Self, ModelMetadataError> {
75 Self::chat_completion(
76 config.api_key,
77 config.base_url,
78 config.model,
79 provider::QWEN_POLICY,
80 )
81 }
82
83 pub fn metadata(&self) -> &ModelMetadata {
86 &self.metadata
87 }
88
89 pub async fn complete(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
97 let _duration = telemetry::RequestDuration::start(self.metadata());
98 let response = self.backend.generate(&request).await?;
99
100 match response {
101 chat_completion::GeneratedResponse::Output(output) => request
102 .schema()
103 .parse_and_validate(&output)
104 .map(ModelResponse::from_output)
105 .map_err(ModelError::from),
106 chat_completion::GeneratedResponse::ToolCall(call) => {
107 Ok(ModelResponse::tool_call(call))
108 }
109 }
110 }
111
112 fn chat_completion(
113 api_key: String,
114 base_url: String,
115 model: String,
116 policy: chat_completion::ChatCompletionProviderPolicy,
117 ) -> Result<Self, ModelMetadataError> {
118 let backend = chat_completion::ChatCompletionBackend::new(api_key, base_url, model, policy);
119 let (provider, model) = backend.identity();
120 let metadata = ModelMetadata::new(provider, model)?;
121
122 Ok(Self { backend, metadata })
123 }
124}
125
126#[async_trait]
127impl Model for ModelClient {
128 async fn complete(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
129 ModelClient::complete(self, request).await
130 }
131}
132
133#[derive(Clone, Debug, Eq, PartialEq)]
135pub struct ModelMetadata {
136 model: String,
137 provider: &'static str,
138}
139
140impl ModelMetadata {
141 pub fn new(
148 provider: &'static str,
149 model: impl Into<String>,
150 ) -> Result<Self, ModelMetadataError> {
151 if provider.trim().is_empty() {
152 return Err(ModelMetadataError::EmptyProvider);
153 }
154 let model = model.into();
155 if model.trim().is_empty() {
156 return Err(ModelMetadataError::EmptyModel);
157 }
158
159 Ok(Self { model, provider })
160 }
161
162 pub fn model(&self) -> &str {
164 &self.model
165 }
166
167 pub fn provider(&self) -> &'static str {
169 self.provider
170 }
171}
172
173#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
175pub enum ModelMetadataError {
176 #[error("model provider must not be empty")]
178 EmptyProvider,
179 #[error("model identifier must not be empty")]
181 EmptyModel,
182}
183
184#[derive(Clone, Debug, Eq, PartialEq)]
186pub struct ModelRequest {
187 messages: Vec<ModelMessage>,
188 prompt: String,
189 schema: OutputSchema,
190 tools: Vec<tool::ToolDefinition>,
191}
192
193impl ModelRequest {
194 pub fn new(prompt: impl Into<String>, schema: OutputSchema) -> Self {
196 let prompt = prompt.into();
197
198 Self {
199 messages: vec![ModelMessage::User(prompt.clone())],
200 prompt,
201 schema,
202 tools: Vec::new(),
203 }
204 }
205
206 #[must_use]
208 pub fn with_tool(mut self, tool: tool::ToolDefinition) -> Self {
209 if !self.advertises_tool(tool.name()) {
210 self.tools.push(tool);
211 }
212
213 self
214 }
215
216 pub fn prompt(&self) -> &str {
218 &self.prompt
219 }
220
221 pub fn schema(&self) -> &OutputSchema {
223 &self.schema
224 }
225
226 pub fn tools(&self) -> &[tool::ToolDefinition] {
228 &self.tools
229 }
230
231 pub(crate) fn advertises_tool(&self, name: &str) -> bool {
232 self.tools.iter().any(|tool| tool.name() == name)
233 }
234
235 pub(crate) fn messages(&self) -> &[ModelMessage] {
236 &self.messages
237 }
238
239 pub(crate) fn record_tool_result(&mut self, call: tool::ToolCall, content: String) {
240 let call_id = call.id().to_string();
241 let name = call.name().to_string();
242 self.messages.push(ModelMessage::AssistantToolCall(call));
243 self.messages.push(ModelMessage::ToolResult {
244 call_id,
245 content,
246 name,
247 });
248 }
249}
250
251#[derive(Clone, Debug, Eq, PartialEq)]
252pub(crate) enum ModelMessage {
253 User(String),
254 AssistantToolCall(tool::ToolCall),
255 ToolResult {
256 call_id: String,
257 content: String,
258 name: String,
259 },
260}
261
262#[derive(Clone, Debug, Eq, PartialEq)]
264pub enum ModelResponse {
265 Output(Value),
267 ToolCall(tool::ToolCall),
269}
270
271impl ModelResponse {
272 pub fn output(&self) -> Option<&Value> {
274 match self {
275 Self::Output(output) => Some(output),
276 Self::ToolCall(_) => None,
277 }
278 }
279
280 pub fn call(&self) -> Option<&tool::ToolCall> {
282 match self {
283 Self::Output(_) => None,
284 Self::ToolCall(call) => Some(call),
285 }
286 }
287
288 fn from_output(output: Value) -> Self {
289 Self::Output(output)
290 }
291
292 fn tool_call(call: tool::ToolCall) -> Self {
293 Self::ToolCall(call)
294 }
295}
296
297#[derive(Debug, Error)]
299pub enum ModelError {
300 #[error("model request failed: {0}")]
302 Request(#[source] Box<dyn Error + Send + Sync>),
303 #[error("model returned no response content")]
305 InvalidResponse,
306 #[error("model response is incomplete: {reason}")]
308 IncompleteResponse {
309 reason: String,
311 },
312 #[error("model response body exceeds the size limit")]
314 ResponseBodyTooLarge,
315 #[error("provider cannot satisfy this output schema: {reason}")]
317 UnsupportedOutputSchema {
318 reason: String,
320 },
321 #[error("model response content exceeds the size limit")]
323 ResponseContentTooLarge,
324 #[error("model returned invalid JSON: {reason}")]
326 InvalidJson {
327 reason: String,
329 },
330 #[error("model output violates the schema at {path}: {reason}")]
332 SchemaViolation {
333 path: String,
336 reason: String,
338 },
339 #[error("model returned no tool call")]
341 MissingToolCall,
342 #[error("model returned multiple tool calls")]
344 MultipleToolCalls,
345 #[error("model tool call response contained terminal content")]
347 ToolCallWithContent,
348 #[error("model terminal response contained tool calls")]
350 TerminalResponseWithToolCalls,
351 #[error("model requested unsupported tool type: {kind}")]
353 UnsupportedToolType {
354 kind: String,
356 },
357 #[error("model requested unsupported tool: {name}")]
359 UnsupportedToolName {
360 name: String,
362 },
363 #[error("model returned invalid tool arguments: {reason}")]
365 InvalidToolArguments {
366 reason: String,
368 },
369}
370
371impl ModelError {
372 pub fn request(error: impl Error + Send + Sync + 'static) -> Self {
374 Self::Request(Box::new(error))
375 }
376}
377
378impl From<OutputValidationError> for ModelError {
379 fn from(error: OutputValidationError) -> Self {
380 match error {
381 OutputValidationError::InvalidJson(reason) => Self::InvalidJson { reason },
382 OutputValidationError::SchemaViolation { path, reason } => {
383 Self::SchemaViolation { path, reason }
384 }
385 OutputValidationError::TooLarge => Self::ResponseContentTooLarge,
386 }
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use std::io;
393
394 use serde_json::json;
395
396 use super::*;
397 use crate::tool::{ReadArguments, ToolCall};
398
399 #[test]
400 fn client_exposes_provider_and_model() {
401 let client = ModelClient::qwen(QwenConfig {
403 api_key: "test-key".to_string(),
404 base_url: "https://example.com".to_string(),
405 model: "qwen-plus".to_string(),
406 })
407 .expect("fixture configuration should be valid");
408
409 let metadata = client.metadata();
411
412 assert_eq!(metadata.provider(), "alibaba_cloud");
414 assert_eq!(metadata.model(), "qwen-plus");
415 assert_eq!(
416 metadata,
417 &ModelMetadata::new("alibaba_cloud", "qwen-plus").expect("metadata should be valid")
418 );
419 }
420
421 #[tokio::test]
422 async fn client_supports_dynamic_model_dispatch() {
423 let model: Box<dyn Model> = Box::new(
425 ModelClient::qwen(QwenConfig {
426 api_key: "test-key".to_string(),
427 base_url: "https://example.com".to_string(),
428 model: "qwen-plus".to_string(),
429 })
430 .expect("fixture configuration should be valid"),
431 );
432 let schema = OutputSchema::new(json!({ "type": "array" })).expect("schema should be valid");
433
434 let error = model
436 .complete(ModelRequest::new("return a list", schema))
437 .await
438 .expect_err("Qwen should reject a non-object schema");
439
440 assert!(matches!(error, ModelError::UnsupportedOutputSchema { .. }));
442 }
443
444 #[test]
445 fn metadata_rejects_empty_provider() {
446 let error =
448 ModelMetadata::new(" ", "stub-large").expect_err("empty provider should be rejected");
449
450 assert_eq!(error, ModelMetadataError::EmptyProvider);
452 assert_eq!(error.to_string(), "model provider must not be empty");
453 }
454
455 #[test]
456 fn metadata_rejects_empty_model() {
457 let error =
459 ModelMetadata::new("stub_provider", " ").expect_err("empty model should be rejected");
460
461 assert_eq!(error, ModelMetadataError::EmptyModel);
463 assert_eq!(error.to_string(), "model identifier must not be empty");
464 }
465
466 #[test]
467 fn request_contains_prompt_and_schema() {
468 let schema =
470 OutputSchema::new(json!({ "type": "object" })).expect("schema should be valid");
471
472 let request = ModelRequest::new("hello", schema.clone());
474
475 assert_eq!(request.prompt(), "hello");
477 assert_eq!(request.schema(), &schema);
478 assert!(request.tools().is_empty());
479 }
480
481 #[test]
482 fn request_explicitly_advertises_read() {
483 let schema =
485 OutputSchema::new(json!({ "type": "object" })).expect("schema should be valid");
486
487 let request = ModelRequest::new("hello", schema).with_tool(tool::ToolDefinition::read());
489
490 assert_eq!(request.tools(), &[tool::ToolDefinition::read()]);
492 assert!(request.advertises_tool("read"));
493 assert!(!request.advertises_tool("write"));
494 }
495
496 #[test]
497 fn request_deduplicates_native_tools() {
498 let schema =
500 OutputSchema::new(json!({ "type": "object" })).expect("schema should be valid");
501
502 let request = ModelRequest::new("hello", schema)
504 .with_tool(tool::ToolDefinition::read())
505 .with_tool(tool::ToolDefinition::read());
506
507 assert_eq!(request.tools(), &[tool::ToolDefinition::read()]);
509 }
510
511 #[test]
512 fn response_exposes_validated_output() {
513 let value = json!({ "name": "Ada" });
515
516 let response = ModelResponse::from_output(value.clone());
518
519 assert_eq!(response.output(), Some(&value));
521 assert!(response.call().is_none());
522 }
523
524 #[test]
525 fn response_debug_redacts_provider_reasoning() {
526 let secret_reasoning = "private reasoning from repository context";
528 let arguments = serde_json::from_value::<ReadArguments>(json!({
529 "path": "Cargo.toml"
530 }))
531 .expect("read arguments should be valid");
532 let response = ModelResponse::tool_call(ToolCall::read(
533 "call_read".to_string(),
534 arguments,
535 Some(secret_reasoning.to_string()),
536 ));
537
538 let debug_output = format!("{response:?}");
540
541 assert!(debug_output.contains("call_read"));
543 assert!(debug_output.contains("[REDACTED]"));
544 assert!(!debug_output.contains(secret_reasoning));
545 }
546
547 #[test]
548 fn invalid_response_error_has_user_facing_message() {
549 let message = ModelError::InvalidResponse.to_string();
551
552 assert_eq!(message, "model returned no response content");
554 }
555
556 #[test]
557 fn incomplete_response_error_includes_reason() {
558 let message = ModelError::IncompleteResponse {
560 reason: "length".to_string(),
561 }
562 .to_string();
563
564 assert_eq!(message, "model response is incomplete: length");
566 }
567
568 #[test]
569 fn request_error_includes_source_message() {
570 let source = io::Error::other("connection refused");
572
573 let message = ModelError::request(source).to_string();
575
576 assert_eq!(message, "model request failed: connection refused");
578 }
579
580 #[test]
581 fn unsupported_schema_error_includes_reason() {
582 let message = ModelError::UnsupportedOutputSchema {
584 reason: "top-level object required".to_string(),
585 }
586 .to_string();
587
588 assert_eq!(
590 message,
591 "provider cannot satisfy this output schema: top-level object required"
592 );
593 }
594
595 #[test]
596 fn oversized_response_body_error_has_user_facing_message() {
597 let message = ModelError::ResponseBodyTooLarge.to_string();
599
600 assert_eq!(message, "model response body exceeds the size limit");
602 }
603
604 #[test]
605 fn converts_invalid_json_error() {
606 let error = OutputValidationError::InvalidJson("expected value".to_string());
608
609 let error = ModelError::from(error);
611
612 assert_eq!(
614 error.to_string(),
615 "model returned invalid JSON: expected value"
616 );
617 }
618
619 #[test]
620 fn converts_schema_violation_error() {
621 let error = OutputValidationError::SchemaViolation {
623 path: "/name".to_string(),
624 reason: "wrong type".to_string(),
625 };
626
627 let error = ModelError::from(error);
629
630 assert_eq!(
632 error.to_string(),
633 "model output violates the schema at /name: wrong type"
634 );
635 }
636
637 #[test]
638 fn converts_oversized_content_error() {
639 let error = ModelError::from(OutputValidationError::TooLarge);
641
642 assert_eq!(
644 error.to_string(),
645 "model response content exceeds the size limit"
646 );
647 }
648}