Skip to main content

mermaid_cli/providers/model/
meta.rs

1//! Meta provider — wraps `models::adapters::meta::MetaAdapter`.
2//!
3//! Same pattern as its four siblings now: the adapter owns the wire format
4//! (the Responses endpoint, stateless encrypted-reasoning replay); this
5//! wrapper plumbs `ChatRequest` / `StreamContext` into it.
6//!
7//! Meta is the second provider that emits a `provider_continuation` which
8//! MUST round-trip — the encrypted reasoning items, replayed verbatim so
9//! thinking survives a tool turn. The adapter's
10//! `ModelResponse.provider_continuation` carries it; we forward that onto
11//! the `FinalResponse` so the reducer can commit it.
12
13use std::collections::HashMap;
14
15use async_trait::async_trait;
16
17use mermaid_domain::{ChatRequest, ToolDefinition};
18use mermaid_model::models::adapters::meta::MetaAdapter;
19use mermaid_model::models::{Model, ModelCapabilities, ModelConfig, ModelError, Result};
20
21use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
22use super::ModelProvider;
23
24pub use mermaid_model::models::adapters::meta::{DEFAULT_API_KEY_ENV, DEFAULT_BASE_URL};
25
26/// Meta adapter fronted by `ModelProvider`.
27pub struct MetaProvider {
28    adapter: MetaAdapter,
29    capabilities: ModelCapabilities,
30}
31
32impl MetaProvider {
33    /// Wrap a fresh [`MetaAdapter`] as a `ModelProvider`.
34    ///
35    /// # Errors
36    ///
37    /// Only [`MetaAdapter::new`]'s — the HTTP client build. The API is not
38    /// contacted here, so an invalid key or unreachable `base_url` still
39    /// constructs and fails on the first request.
40    pub fn new(
41        api_key: String,
42        model_name: String,
43        base_url: String,
44        extra_headers: HashMap<String, String>,
45    ) -> Result<Self> {
46        let adapter = MetaAdapter::new(api_key, model_name, base_url, extra_headers)?;
47        let capabilities = Model::capabilities(&adapter).clone();
48        Ok(Self {
49            adapter,
50            capabilities,
51        })
52    }
53}
54
55#[async_trait]
56impl ModelProvider for MetaProvider {
57    fn capabilities(&self) -> &ModelCapabilities {
58        &self.capabilities
59    }
60
61    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
62        let config = build_model_config(&request);
63        let chat_fut = self
64            .adapter
65            .chat(&request.messages, &config, Some(ctx.sink.clone()));
66
67        // Meta used to select on the token inside its own read loop. The
68        // outer race is equivalent and is what the other four do: dropping
69        // this future drops the response stream with it.
70        let response = tokio::select! {
71            biased;
72            _ = ctx.token.cancelled() => {
73                return Err(ModelError::Cancelled);
74            },
75            r = chat_fut => r?,
76        };
77
78        let usage = response.usage.clone();
79        let provider_continuation = response.provider_continuation.clone();
80        let stop_reason = response.stop_reason.clone();
81        // F3: the terminal Done goes on the same sink the adapter just
82        // finished writing to, so it cannot overtake a still-queued ToolCall.
83        let _ = ctx
84            .sink
85            .send(StreamEvent::Done {
86                usage: usage.clone(),
87                provider_continuation: provider_continuation.clone(),
88                stop_reason: stop_reason.clone(),
89            })
90            .await;
91
92        Ok(FinalResponse {
93            usage,
94            provider_continuation,
95            tool_calls: response.tool_calls.unwrap_or_default(),
96            stop_reason,
97        })
98    }
99}
100
101fn build_model_config(request: &ChatRequest) -> ModelConfig {
102    ModelConfig {
103        model: request.model_id.clone(),
104        temperature: request.temperature,
105        max_tokens: request.max_tokens,
106        reasoning: request.reasoning,
107        system_prompt: Some(request.system_prompt.clone()),
108        dynamic_system_suffix: request.instructions.clone(),
109        tools: request
110            .tools
111            .iter()
112            .map(ToolDefinition::to_openai_json)
113            .collect(),
114        resolved_context_window: request.resolved_context_window,
115        resolved_max_output: request.resolved_max_output,
116        output_schema: request.output_schema.clone(),
117        ..Default::default()
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use mermaid_domain::ToolDefinition;
125
126    #[test]
127    fn build_model_config_maps_fields() {
128        let req = ChatRequest {
129            model_id: "meta/muse-spark-1.1".to_string(),
130            messages: vec![],
131            system_prompt: "system".to_string(),
132            instructions: Some("project".to_string()),
133            reasoning: mermaid_model::models::ReasoningLevel::Max,
134            temperature: 0.7,
135            max_tokens: 200_000,
136            tools: vec![ToolDefinition {
137                name: "read_file".to_string(),
138                description: "Read a file".to_string(),
139                input_schema: serde_json::json!({"type": "object"}),
140            }],
141            ollama_num_ctx: None,
142            ollama_allow_ram_offload: None,
143            resolved_context_window: Some(mermaid_model::constants::META_MUSE_SPARK_CONTEXT_WINDOW),
144            resolved_max_output: Some(mermaid_model::constants::META_MUSE_SPARK_MAX_OUTPUT_TOKENS),
145            output_schema: None,
146            suppress_auto_compact: false,
147            suppressed_builtin_tools: Vec::new(),
148        };
149        let cfg = build_model_config(&req);
150        assert_eq!(cfg.dynamic_system_suffix.as_deref(), Some("project"));
151        assert_eq!(
152            cfg.resolved_max_output,
153            Some(mermaid_model::constants::META_MUSE_SPARK_MAX_OUTPUT_TOKENS)
154        );
155        // The adapter unwraps this envelope into the Responses shape; the
156        // wrapper's job is only to produce it the same way every other
157        // provider does.
158        assert_eq!(cfg.tools[0]["function"]["name"], "read_file");
159    }
160}