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
15pub const MUSE_SPARK_1_2: &str = "muse-spark-1.2";
18
19pub 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
32pub struct Muse {
34 client: ModelClient,
35}
36
37impl Muse {
38 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#[derive(Debug, Error)]
75pub enum MuseError {
76 #[error("MODEL_API_KEY is unavailable: {0}")]
78 ApiKey(#[source] env::VarError),
79 #[error(transparent)]
81 Metadata(#[from] ModelMetadataError),
82}
83
84pub struct MuseConfig {
86 pub api_key: String,
88 pub base_url: String,
90 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 let models = [MUSE_SPARK_1_2, MUSE_SPARK_1_2_CONTRIBUTOR];
172
173 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 let muse = Muse::from_environment(MUSE_SPARK_1_2, default_environment)
181 .expect("fixture environment should be valid");
182
183 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 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!(matches!(
197 error,
198 MuseError::ApiKey(env::VarError::NotPresent)
199 ));
200 }
201
202 #[test]
203 fn environment_configuration_rejects_empty_model() {
204 let error = Muse::from_environment(" ", default_environment)
206 .err()
207 .expect("empty model should fail");
208
209 assert!(matches!(
211 error,
212 MuseError::Metadata(ModelMetadataError::EmptyModel)
213 ));
214 }
215
216 #[test]
217 fn metadata_exposes_provider_and_model() {
218 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 let metadata = model.metadata();
228
229 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 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 let error = model::ModelClient::muse(config)
245 .err()
246 .expect("empty model configuration should be rejected");
247
248 assert_eq!(error, model::ModelMetadataError::EmptyModel);
250 }
251
252 #[tokio::test]
253 async fn completes_native_json_schema_request() {
254 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 let response = model
279 .complete(request("extract the name"))
280 .await
281 .expect("Muse request should succeed");
282
283 assert_eq!(response.output(), Some(&json!({ "name": "Ada" })));
285 }
286
287 #[tokio::test]
288 async fn advertises_and_decodes_read_tool_call() {
289 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 let response = model
325 .complete(read_request("inspect the manifest"))
326 .await
327 .expect("Muse read request should decode");
328
329 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 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 let response = model
428 .complete(request)
429 .await
430 .expect("Muse continuation should succeed");
431
432 assert_eq!(response.output(), Some(&json!({ "name": "ag-harness" })));
434 }
435
436 #[tokio::test]
437 async fn retains_local_schema_validation() {
438 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 let error = model
454 .complete(request("extract the name"))
455 .await
456 .expect_err("schema violation should fail");
457
458 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 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 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!(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 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 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!(message.contains("Meta Model API returned HTTP 401 Unauthorized"));
517 assert!(message.contains("invalid API key"));
518 assert!(!message.contains("test-key"));
519 }
520}