Skip to main content

ag_harness/provider/
muse.rs

1use std::env;
2
3use async_trait::async_trait;
4use thiserror::Error;
5
6use crate::chat_completion;
7use crate::model::{
8    Model, ModelClient, ModelError, ModelMetadataError, ModelRequest, ModelResponse,
9};
10
11const DEFAULT_BASE_URL: &str = "https://api.meta.ai/v1";
12const MODEL_API_BASE_URL_ENV: &str = "MODEL_API_BASE_URL";
13const MODEL_API_KEY_ENV: &str = "MODEL_API_KEY";
14
15/// Standard Muse Spark 1.2 model whose prompts and completions are not used
16/// to train Meta models.
17pub const MUSE_SPARK_1_2: &str = "muse-spark-1.2";
18
19/// Discounted Muse Spark 1.2 model that permits Meta to use prompts and
20/// completions to train future models.
21pub const MUSE_SPARK_1_2_CONTRIBUTOR: &str = "muse-spark-1.2-contributor";
22
23pub(crate) const PROVIDER_NAME: &str = "meta";
24pub(crate) const POLICY: chat_completion::ChatCompletionProviderPolicy =
25    chat_completion::ChatCompletionProviderPolicy {
26        display_name: "Meta Model API",
27        structured_output: chat_completion::StructuredOutputMode::JsonSchema,
28        telemetry_name: PROVIDER_NAME,
29        unsupported_schema_reason: "Muse structured output requires an explicit object root schema",
30    };
31
32/// Muse model configured from the standard Model API environment variables.
33pub struct Muse {
34    client: ModelClient,
35}
36
37impl Muse {
38    /// Creates a Muse model using `MODEL_API_KEY` and the optional
39    /// `MODEL_API_BASE_URL` override.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`MuseError`] when `MODEL_API_KEY` is unavailable or `model` is
44    /// empty.
45    pub fn from_env(model: impl Into<String>) -> Result<Self, MuseError> {
46        Self::from_environment(model, |name| env::var(name))
47    }
48
49    fn from_environment(
50        model: impl Into<String>,
51        mut environment: impl FnMut(&str) -> Result<String, env::VarError>,
52    ) -> Result<Self, MuseError> {
53        let api_key = environment(MODEL_API_KEY_ENV).map_err(MuseError::ApiKey)?;
54        let base_url =
55            environment(MODEL_API_BASE_URL_ENV).unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
56        let client = ModelClient::muse(MuseConfig {
57            api_key,
58            base_url,
59            model: model.into(),
60        })?;
61
62        Ok(Self { client })
63    }
64}
65
66#[async_trait]
67impl Model for Muse {
68    async fn complete(&self, request: ModelRequest) -> Result<ModelResponse, ModelError> {
69        self.client.complete(request).await
70    }
71}
72
73/// Failure returned while configuring Muse from the environment.
74#[derive(Debug, Error)]
75pub enum MuseError {
76    /// `MODEL_API_KEY` is missing or is not valid Unicode.
77    #[error("MODEL_API_KEY is unavailable: {0}")]
78    ApiKey(#[source] env::VarError),
79    /// The selected model identifier is invalid.
80    #[error(transparent)]
81    Metadata(#[from] ModelMetadataError),
82}
83
84/// Configuration for a Muse model served through Meta's Model API.
85pub struct MuseConfig {
86    /// API key sent as a bearer token.
87    pub api_key: String,
88    /// API base URL ending in the OpenAI-compatible version path.
89    pub base_url: String,
90    /// Muse model identifier sent with each request.
91    pub model: String,
92}
93
94#[cfg(test)]
95mod tests {
96    use serde_json::{Value, json};
97    use wiremock::matchers::{bearer_token, body_json, method, path};
98    use wiremock::{Mock, MockServer, ResponseTemplate};
99
100    use super::*;
101    use crate::{model, tool};
102
103    fn person_schema_value() -> Value {
104        json!({
105            "type": "object",
106            "properties": {
107                "name": { "type": "string" }
108            },
109            "required": ["name"],
110            "additionalProperties": false
111        })
112    }
113
114    fn person_schema() -> crate::OutputSchema {
115        crate::OutputSchema::new(person_schema_value()).expect("schema should be valid")
116    }
117
118    fn request(prompt: &str) -> model::ModelRequest {
119        model::ModelRequest::new(prompt, person_schema())
120    }
121
122    fn read_request(prompt: &str) -> model::ModelRequest {
123        request(prompt).with_tool(tool::ToolDefinition::read())
124    }
125
126    fn read_tool_wire() -> Value {
127        let definition = tool::ToolDefinition::read();
128
129        json!({
130            "type": "function",
131            "function": {
132                "description": definition.description(),
133                "name": definition.name(),
134                "parameters": definition.parameters()
135            }
136        })
137    }
138
139    fn default_environment(name: &str) -> Result<String, env::VarError> {
140        if name == MODEL_API_KEY_ENV {
141            Ok("test-key".to_string())
142        } else {
143            Err(env::VarError::NotPresent)
144        }
145    }
146
147    fn muse(server: &MockServer) -> Muse {
148        Muse::from_environment(MUSE_SPARK_1_2, |name| {
149            if name == MODEL_API_KEY_ENV {
150                Ok("test-key".to_string())
151            } else {
152                Ok(format!("{}/", server.uri()))
153            }
154        })
155        .expect("fixture environment should be valid")
156    }
157
158    fn response_format() -> Value {
159        json!({
160            "type": "json_schema",
161            "json_schema": {
162                "name": "ag_harness_output",
163                "schema": person_schema_value()
164            }
165        })
166    }
167
168    #[test]
169    fn exposes_standard_and_contributor_model_identifiers() {
170        // Arrange and Act
171        let models = [MUSE_SPARK_1_2, MUSE_SPARK_1_2_CONTRIBUTOR];
172
173        // Assert
174        assert_eq!(models, ["muse-spark-1.2", "muse-spark-1.2-contributor"]);
175    }
176
177    #[test]
178    fn environment_configuration_uses_official_base_url_default() {
179        // Arrange and Act
180        let muse = Muse::from_environment(MUSE_SPARK_1_2, default_environment)
181            .expect("fixture environment should be valid");
182
183        // Assert
184        assert_eq!(muse.client.metadata().provider(), "meta");
185        assert_eq!(muse.client.metadata().model(), MUSE_SPARK_1_2);
186    }
187
188    #[test]
189    fn environment_configuration_requires_api_key() {
190        // Arrange and Act
191        let error = Muse::from_environment(MUSE_SPARK_1_2, |_| Err(env::VarError::NotPresent))
192            .err()
193            .expect("missing API key should fail");
194
195        // Assert
196        assert!(matches!(
197            error,
198            MuseError::ApiKey(env::VarError::NotPresent)
199        ));
200    }
201
202    #[test]
203    fn environment_configuration_rejects_empty_model() {
204        // Arrange and Act
205        let error = Muse::from_environment("  ", default_environment)
206            .err()
207            .expect("empty model should fail");
208
209        // Assert
210        assert!(matches!(
211            error,
212            MuseError::Metadata(ModelMetadataError::EmptyModel)
213        ));
214    }
215
216    #[test]
217    fn metadata_exposes_provider_and_model() {
218        // Arrange
219        let model = model::ModelClient::muse(MuseConfig {
220            api_key: "test-key".to_string(),
221            base_url: "https://api.meta.ai/v1".to_string(),
222            model: MUSE_SPARK_1_2_CONTRIBUTOR.to_string(),
223        })
224        .expect("fixture configuration should be valid");
225
226        // Act
227        let metadata = model.metadata();
228
229        // Assert
230        assert_eq!(metadata.provider(), "meta");
231        assert_eq!(metadata.model(), "muse-spark-1.2-contributor");
232    }
233
234    #[test]
235    fn rejects_empty_model_during_construction() {
236        // Arrange
237        let config = MuseConfig {
238            api_key: "test-key".to_string(),
239            base_url: "https://api.meta.ai/v1".to_string(),
240            model: "  ".to_string(),
241        };
242
243        // Act
244        let error = model::ModelClient::muse(config)
245            .err()
246            .expect("empty model configuration should be rejected");
247
248        // Assert
249        assert_eq!(error, model::ModelMetadataError::EmptyModel);
250    }
251
252    #[tokio::test]
253    async fn completes_native_json_schema_request() {
254        // Arrange
255        let server = MockServer::start().await;
256        Mock::given(method("POST"))
257            .and(path("/chat/completions"))
258            .and(bearer_token("test-key"))
259            .and(body_json(json!({
260                "messages": [
261                    {"content": "extract the name", "role": "user"}
262                ],
263                "model": "muse-spark-1.2",
264                "response_format": response_format()
265            })))
266            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
267                "choices": [{
268                    "finish_reason": "stop",
269                    "message": {"content": r#"{"name":"Ada"}"#}
270                }]
271            })))
272            .expect(1)
273            .mount(&server)
274            .await;
275        let model = muse(&server);
276
277        // Act
278        let response = model
279            .complete(request("extract the name"))
280            .await
281            .expect("Muse request should succeed");
282
283        // Assert
284        assert_eq!(response.output(), Some(&json!({ "name": "Ada" })));
285    }
286
287    #[tokio::test]
288    async fn advertises_and_decodes_read_tool_call() {
289        // Arrange
290        let server = MockServer::start().await;
291        Mock::given(method("POST"))
292            .and(path("/chat/completions"))
293            .and(bearer_token("test-key"))
294            .and(body_json(json!({
295                "messages": [
296                    {"content": "inspect the manifest", "role": "user"}
297                ],
298                "model": "muse-spark-1.2",
299                "response_format": response_format(),
300                "tools": [read_tool_wire()]
301            })))
302            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
303                "choices": [{
304                    "finish_reason": "tool_calls",
305                    "message": {
306                        "content": null,
307                        "tool_calls": [{
308                            "id": "call_muse_read",
309                            "type": "function",
310                            "function": {
311                                "name": "read",
312                                "arguments": r#"{"path":"Cargo.toml","offset":1,"limit":12}"#
313                            }
314                        }]
315                    }
316                }]
317            })))
318            .expect(1)
319            .mount(&server)
320            .await;
321        let model = muse(&server);
322
323        // Act
324        let response = model
325            .complete(read_request("inspect the manifest"))
326            .await
327            .expect("Muse read request should decode");
328
329        // Assert
330        assert!(response.output().is_none());
331        let call = response
332            .call()
333            .expect("response should contain a tool call");
334        assert_eq!(call.id(), "call_muse_read");
335        assert_eq!(call.name(), "read");
336        assert_eq!(call.arguments().path(), "Cargo.toml");
337        assert_eq!(call.arguments().offset(), Some(1));
338        assert_eq!(call.arguments().limit(), Some(12));
339    }
340
341    #[tokio::test]
342    async fn continues_after_read_tool_result() {
343        // Arrange
344        let server = MockServer::start().await;
345        Mock::given(method("POST"))
346            .and(path("/chat/completions"))
347            .and(bearer_token("test-key"))
348            .and(body_json(json!({
349                "messages": [
350                    {"content": "inspect the manifest", "role": "user"}
351                ],
352                "model": "muse-spark-1.2",
353                "response_format": response_format(),
354                "tools": [read_tool_wire()]
355            })))
356            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
357                "choices": [{
358                    "finish_reason": "tool_calls",
359                    "message": {
360                        "content": null,
361                        "tool_calls": [{
362                            "id": "call_muse_read",
363                            "type": "function",
364                            "function": {
365                                "name": "read",
366                                "arguments": r#"{"path":"Cargo.toml"}"#
367                            }
368                        }]
369                    }
370                }]
371            })))
372            .expect(1)
373            .mount(&server)
374            .await;
375        let model = muse(&server);
376        let mut request = read_request("inspect the manifest");
377        let response = model
378            .complete(request.clone())
379            .await
380            .expect("Muse read request should decode");
381        request.record_tool_result(
382            response
383                .call()
384                .expect("response should contain a tool call")
385                .clone(),
386            r#"{"content":"[package]\nname = \"ag-harness\"","next_offset":null}"#.to_string(),
387        );
388        Mock::given(method("POST"))
389            .and(path("/chat/completions"))
390            .and(bearer_token("test-key"))
391            .and(body_json(json!({
392                "messages": [
393                    {"content": "inspect the manifest", "role": "user"},
394                    {
395                        "content": null,
396                        "role": "assistant",
397                        "tool_calls": [{
398                            "function": {
399                                "arguments": r#"{"path":"Cargo.toml"}"#,
400                                "name": "read"
401                            },
402                            "id": "call_muse_read",
403                            "type": "function"
404                        }]
405                    },
406                    {
407                        "content": r#"{"content":"[package]\nname = \"ag-harness\"","next_offset":null}"#,
408                        "role": "tool",
409                        "tool_call_id": "call_muse_read"
410                    }
411                ],
412                "model": "muse-spark-1.2",
413                "response_format": response_format(),
414                "tools": [read_tool_wire()]
415            })))
416            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
417                "choices": [{
418                    "finish_reason": "stop",
419                    "message": {"content": r#"{"name":"ag-harness"}"#}
420                }]
421            })))
422            .expect(1)
423            .mount(&server)
424            .await;
425
426        // Act
427        let response = model
428            .complete(request)
429            .await
430            .expect("Muse continuation should succeed");
431
432        // Assert
433        assert_eq!(response.output(), Some(&json!({ "name": "ag-harness" })));
434    }
435
436    #[tokio::test]
437    async fn retains_local_schema_validation() {
438        // Arrange
439        let server = MockServer::start().await;
440        Mock::given(method("POST"))
441            .and(path("/chat/completions"))
442            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
443                "choices": [{
444                    "finish_reason": "stop",
445                    "message": {"content": r#"{"name":42}"#}
446                }]
447            })))
448            .mount(&server)
449            .await;
450        let model = muse(&server);
451
452        // Act
453        let error = model
454            .complete(request("extract the name"))
455            .await
456            .expect_err("schema violation should fail");
457
458        // Assert
459        assert!(matches!(
460            error,
461            model::ModelError::SchemaViolation { path, reason }
462                if path == "/name" && reason.contains("string")
463        ));
464    }
465
466    #[tokio::test]
467    async fn rejects_schemas_without_explicit_object_root() {
468        // Arrange
469        let server = MockServer::start().await;
470        let model = muse(&server);
471        let schema =
472            crate::OutputSchema::new(json!({ "type": "array" })).expect("schema should be valid");
473
474        // Act
475        let error = model
476            .complete(model::ModelRequest::new("list names", schema))
477            .await
478            .expect_err("schema without an explicit object root should fail");
479
480        // Assert
481        assert!(matches!(
482            error,
483            model::ModelError::UnsupportedOutputSchema { reason }
484                if reason == "Muse structured output requires an explicit object root schema"
485        ));
486        assert!(
487            server
488                .received_requests()
489                .await
490                .expect("request recording should be enabled")
491                .is_empty()
492        );
493    }
494
495    #[tokio::test]
496    async fn reports_meta_http_failure_without_exposing_the_key() {
497        // Arrange
498        let server = MockServer::start().await;
499        Mock::given(method("POST"))
500            .and(path("/chat/completions"))
501            .respond_with(ResponseTemplate::new(401).set_body_json(json!({
502                "error": {"message": "invalid API key"}
503            })))
504            .mount(&server)
505            .await;
506        let model = muse(&server);
507
508        // Act
509        let error = model
510            .complete(request("hello"))
511            .await
512            .expect_err("HTTP failure should fail");
513        let message = error.to_string();
514
515        // Assert
516        assert!(message.contains("Meta Model API returned HTTP 401 Unauthorized"));
517        assert!(message.contains("invalid API key"));
518        assert!(!message.contains("test-key"));
519    }
520}