Skip to main content

ag_harness/
model.rs

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/// Object-safe boundary for provider-neutral model requests.
12///
13/// [`ModelClient`] implements this trait so applications can select supported
14/// providers dynamically without exposing provider backends or raw generation.
15#[cfg_attr(test, mockall::automock)]
16#[async_trait]
17pub trait Model: Send + Sync {
18    /// Completes one model request.
19    ///
20    /// # Errors
21    ///
22    /// Returns [`ModelError`] when the provider request fails or its response
23    /// cannot be converted to the provider-neutral response.
24    async fn complete(&self, request: ModelRequest) -> Result<ModelResponse, ModelError>;
25}
26
27/// Application-facing client for provider-neutral model requests.
28///
29/// Provider request execution remains private so every request passes through
30/// [`ModelClient::complete`], which owns telemetry and structured-output
31/// validation.
32pub struct ModelClient {
33    backend: chat_completion::ChatCompletionBackend,
34    metadata: ModelMetadata,
35}
36
37impl ModelClient {
38    /// Creates a client backed by Moonshot AI's Kimi API.
39    ///
40    /// # Errors
41    ///
42    /// Returns [`ModelMetadataError`] when the configured model identifier is
43    /// empty or contains only whitespace.
44    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    /// Creates a client backed by Meta's Model API for Muse models.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`ModelMetadataError`] when the configured model identifier is
58    /// empty or contains only whitespace.
59    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    /// Creates a client backed by Alibaba Cloud Model Studio's Qwen API.
69    ///
70    /// # Errors
71    ///
72    /// Returns [`ModelMetadataError`] when the configured model identifier is
73    /// empty or contains only whitespace.
74    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    /// Returns the validated provider and model identity retained by the
84    /// client.
85    pub fn metadata(&self) -> &ModelMetadata {
86        &self.metadata
87    }
88
89    /// Completes one model request through the shared telemetry and
90    /// structured-output lifecycle.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`ModelError`] when the provider request fails or its response
95    /// cannot be converted to the provider-neutral response.
96    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/// Validated provider and model identity used by the shared client lifecycle.
134#[derive(Clone, Debug, Eq, PartialEq)]
135pub struct ModelMetadata {
136    model: String,
137    provider: &'static str,
138}
139
140impl ModelMetadata {
141    /// Creates metadata for one provider model.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`ModelMetadataError`] when `provider` or `model` is empty or
146    /// contains only whitespace.
147    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    /// Returns the model identifier sent to the provider.
163    pub fn model(&self) -> &str {
164        &self.model
165    }
166
167    /// Returns the provider identifier used by telemetry.
168    pub fn provider(&self) -> &'static str {
169        self.provider
170    }
171}
172
173/// Invalid identity attributes supplied by a model provider.
174#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
175pub enum ModelMetadataError {
176    /// The provider identifier is empty or contains only whitespace.
177    #[error("model provider must not be empty")]
178    EmptyProvider,
179    /// The model identifier is empty or contains only whitespace.
180    #[error("model identifier must not be empty")]
181    EmptyModel,
182}
183
184/// Provider-neutral input for one model request.
185#[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    /// Creates a model request whose response must match `schema`.
195    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    /// Advertises one native function tool for this request.
207    #[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    /// Returns the request prompt.
217    pub fn prompt(&self) -> &str {
218        &self.prompt
219    }
220
221    /// Returns the schema that the response must match.
222    pub fn schema(&self) -> &OutputSchema {
223        &self.schema
224    }
225
226    /// Returns the native function tools explicitly advertised by the caller.
227    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/// Provider-neutral output from one model request.
263#[derive(Clone, Debug, Eq, PartialEq)]
264pub enum ModelResponse {
265    /// Terminal, locally schema-validated model output.
266    Output(Value),
267    /// One validated native function call requiring application handling.
268    ToolCall(tool::ToolCall),
269}
270
271impl ModelResponse {
272    /// Returns parsed, schema-validated terminal output, when present.
273    pub fn output(&self) -> Option<&Value> {
274        match self {
275            Self::Output(output) => Some(output),
276            Self::ToolCall(_) => None,
277        }
278    }
279
280    /// Returns the intermediate native function call, when present.
281    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/// Failure returned while completing a model request.
298#[derive(Debug, Error)]
299pub enum ModelError {
300    /// The provider request or response decoding failed.
301    #[error("model request failed: {0}")]
302    Request(#[source] Box<dyn Error + Send + Sync>),
303    /// The provider returned a successful response without assistant content.
304    #[error("model returned no response content")]
305    InvalidResponse,
306    /// The provider stopped before completing the model response.
307    #[error("model response is incomplete: {reason}")]
308    IncompleteResponse {
309        /// Provider-specific reason generation stopped.
310        reason: String,
311    },
312    /// The successful provider response body exceeds the adapter safety limit.
313    #[error("model response body exceeds the size limit")]
314    ResponseBodyTooLarge,
315    /// The provider cannot represent the requested output schema.
316    #[error("provider cannot satisfy this output schema: {reason}")]
317    UnsupportedOutputSchema {
318        /// Provider-specific reason the schema cannot be represented.
319        reason: String,
320    },
321    /// The decoded provider response content exceeds the harness safety limit.
322    #[error("model response content exceeds the size limit")]
323    ResponseContentTooLarge,
324    /// The provider returned malformed JSON for a structured request.
325    #[error("model returned invalid JSON: {reason}")]
326    InvalidJson {
327        /// JSON parser diagnostic without the raw response body.
328        reason: String,
329    },
330    /// The returned JSON does not conform to the requested schema.
331    #[error("model output violates the schema at {path}: {reason}")]
332    SchemaViolation {
333        /// Bounded JSON Pointer-like path to the invalid value, or `$` for the
334        /// root.
335        path: String,
336        /// Validator diagnostic for the failed constraint.
337        reason: String,
338    },
339    /// The provider returned tool calls without any call entries.
340    #[error("model returned no tool call")]
341    MissingToolCall,
342    /// The provider returned more than the single supported call.
343    #[error("model returned multiple tool calls")]
344    MultipleToolCalls,
345    /// A tool-call response also contained terminal assistant content.
346    #[error("model tool call response contained terminal content")]
347    ToolCallWithContent,
348    /// A terminal response also contained native tool calls.
349    #[error("model terminal response contained tool calls")]
350    TerminalResponseWithToolCalls,
351    /// The provider returned an unsupported tool-call type.
352    #[error("model requested unsupported tool type: {kind}")]
353    UnsupportedToolType {
354        /// Provider tool type that is not a native function.
355        kind: String,
356    },
357    /// The provider returned an unsupported or unadvertised native function.
358    #[error("model requested unsupported tool: {name}")]
359    UnsupportedToolName {
360        /// Native function name that was not advertised for the request.
361        name: String,
362    },
363    /// The provider returned malformed or invalid native function arguments.
364    #[error("model returned invalid tool arguments: {reason}")]
365    InvalidToolArguments {
366        /// Bounded parser or validation diagnostic.
367        reason: String,
368    },
369}
370
371impl ModelError {
372    /// Wraps a provider transport or response-decoding failure.
373    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        // Arrange
402        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        // Act
410        let metadata = client.metadata();
411
412        // Assert
413        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        // Arrange
424        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        // Act
435        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
441        assert!(matches!(error, ModelError::UnsupportedOutputSchema { .. }));
442    }
443
444    #[test]
445    fn metadata_rejects_empty_provider() {
446        // Arrange and Act
447        let error =
448            ModelMetadata::new("  ", "stub-large").expect_err("empty provider should be rejected");
449
450        // Assert
451        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        // Arrange and Act
458        let error =
459            ModelMetadata::new("stub_provider", "  ").expect_err("empty model should be rejected");
460
461        // Assert
462        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        // Arrange
469        let schema =
470            OutputSchema::new(json!({ "type": "object" })).expect("schema should be valid");
471
472        // Act
473        let request = ModelRequest::new("hello", schema.clone());
474
475        // Assert
476        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        // Arrange
484        let schema =
485            OutputSchema::new(json!({ "type": "object" })).expect("schema should be valid");
486
487        // Act
488        let request = ModelRequest::new("hello", schema).with_tool(tool::ToolDefinition::read());
489
490        // Assert
491        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        // Arrange
499        let schema =
500            OutputSchema::new(json!({ "type": "object" })).expect("schema should be valid");
501
502        // Act
503        let request = ModelRequest::new("hello", schema)
504            .with_tool(tool::ToolDefinition::read())
505            .with_tool(tool::ToolDefinition::read());
506
507        // Assert
508        assert_eq!(request.tools(), &[tool::ToolDefinition::read()]);
509    }
510
511    #[test]
512    fn response_exposes_validated_output() {
513        // Arrange
514        let value = json!({ "name": "Ada" });
515
516        // Act
517        let response = ModelResponse::from_output(value.clone());
518
519        // Assert
520        assert_eq!(response.output(), Some(&value));
521        assert!(response.call().is_none());
522    }
523
524    #[test]
525    fn response_debug_redacts_provider_reasoning() {
526        // Arrange
527        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        // Act
539        let debug_output = format!("{response:?}");
540
541        // Assert
542        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        // Arrange and Act
550        let message = ModelError::InvalidResponse.to_string();
551
552        // Assert
553        assert_eq!(message, "model returned no response content");
554    }
555
556    #[test]
557    fn incomplete_response_error_includes_reason() {
558        // Arrange and Act
559        let message = ModelError::IncompleteResponse {
560            reason: "length".to_string(),
561        }
562        .to_string();
563
564        // Assert
565        assert_eq!(message, "model response is incomplete: length");
566    }
567
568    #[test]
569    fn request_error_includes_source_message() {
570        // Arrange
571        let source = io::Error::other("connection refused");
572
573        // Act
574        let message = ModelError::request(source).to_string();
575
576        // Assert
577        assert_eq!(message, "model request failed: connection refused");
578    }
579
580    #[test]
581    fn unsupported_schema_error_includes_reason() {
582        // Arrange and Act
583        let message = ModelError::UnsupportedOutputSchema {
584            reason: "top-level object required".to_string(),
585        }
586        .to_string();
587
588        // Assert
589        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        // Arrange and Act
598        let message = ModelError::ResponseBodyTooLarge.to_string();
599
600        // Assert
601        assert_eq!(message, "model response body exceeds the size limit");
602    }
603
604    #[test]
605    fn converts_invalid_json_error() {
606        // Arrange
607        let error = OutputValidationError::InvalidJson("expected value".to_string());
608
609        // Act
610        let error = ModelError::from(error);
611
612        // Assert
613        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        // Arrange
622        let error = OutputValidationError::SchemaViolation {
623            path: "/name".to_string(),
624            reason: "wrong type".to_string(),
625        };
626
627        // Act
628        let error = ModelError::from(error);
629
630        // Assert
631        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        // Arrange and Act
640        let error = ModelError::from(OutputValidationError::TooLarge);
641
642        // Assert
643        assert_eq!(
644            error.to_string(),
645            "model response content exceeds the size limit"
646        );
647    }
648}