Skip to main content

vtcode_llm/providers/
meta.rs

1//! Official Meta AI OpenAI-compatible provider.
2
3use serde_json::{Map, Value};
4use vtcode_config::constants::{env_vars, models, urls};
5use vtcode_config::types::ReasoningEffortLevel;
6
7use super::common::validate_supported_models;
8use super::openai_compat::{OpenAiCompatCore, OpenAiCompatSpec, impl_openai_compat_provider};
9use crate::provider::{LLMError, LLMRequest, ToolChoice};
10
11/// Wire-dialect description for the official Meta AI API.
12pub struct MetaSpec;
13
14fn reasoning_effort_value(effort: ReasoningEffortLevel) -> Option<&'static str> {
15    match effort {
16        ReasoningEffortLevel::None | ReasoningEffortLevel::Unknown => None,
17        ReasoningEffortLevel::Minimal => Some("minimal"),
18        ReasoningEffortLevel::Low => Some("low"),
19        ReasoningEffortLevel::Medium => Some("medium"),
20        ReasoningEffortLevel::High => Some("high"),
21        ReasoningEffortLevel::XHigh => Some("xhigh"),
22        ReasoningEffortLevel::Max => Some("max"),
23    }
24}
25
26impl OpenAiCompatSpec for MetaSpec {
27    const NAME: &'static str = "Meta AI";
28    const KEY: &'static str = "meta";
29    const API_KEY_ENV: &'static str = "META_API_KEY";
30    const DEFAULT_MODEL: &'static str = models::meta::DEFAULT_MODEL;
31    const DEFAULT_BASE_URL: &'static str = urls::META_API_BASE;
32    const BASE_URL_ENV: Option<&'static str> = Some(env_vars::META_BASE_URL);
33    const LISTED_MODELS: &'static [&'static str] = models::meta::SUPPORTED_MODELS;
34    const VALIDATION_ALLOWLIST: Option<&'static [&'static str]> = Some(models::meta::SUPPORTED_MODELS);
35    const MAX_TOKENS_KEY: &'static str = "max_completion_tokens";
36    const SUPPRESS_SAMPLING_WHEN_REASONING: bool = false;
37    const STREAM_OPTIONS_INCLUDE_USAGE: bool = false;
38    const STREAM_REASONING_FIELDS: &'static [&'static str] = &[];
39    const VALIDATE_ON_GENERATE: bool = true;
40
41    fn resolve_api_key(api_key: Option<String>) -> String {
42        api_key
43            .filter(|key| !key.trim().is_empty())
44            .or_else(|| std::env::var(Self::API_KEY_ENV).ok().filter(|key| !key.trim().is_empty()))
45            // Meta's documentation calls the official variable MODEL_API_KEY.
46            .or_else(|| std::env::var("MODEL_API_KEY").ok().filter(|key| !key.trim().is_empty()))
47            .unwrap_or_default()
48    }
49
50    fn insert_tool_choice(_core: &OpenAiCompatCore<Self>, request: &LLMRequest, payload: &mut Map<String, Value>) {
51        if matches!(request.tool_choice, Some(ToolChoice::None)) {
52            payload.remove("tools");
53            return;
54        }
55        // Meta Chat Completions defaults to `auto`; only emit the documented
56        // supported choice when callers explicitly select it.
57        if request.tools.as_ref().is_some_and(|tools| !tools.is_empty())
58            && matches!(request.tool_choice, Some(ToolChoice::Auto))
59        {
60            payload.insert("tool_choice".to_owned(), Value::String("auto".to_owned()));
61        }
62    }
63
64    fn insert_reasoning(
65        _core: &OpenAiCompatCore<Self>,
66        request: &LLMRequest,
67        payload: &mut Map<String, Value>,
68    ) -> Result<(), LLMError> {
69        if let Some(effort) = request.reasoning_effort
70            && let Some(value) = reasoning_effort_value(effort)
71        {
72            payload.insert("reasoning_effort".to_owned(), Value::String(value.to_owned()));
73        }
74        Ok(())
75    }
76
77    fn finish_payload(
78        _core: &OpenAiCompatCore<Self>,
79        request: &LLMRequest,
80        payload: &mut Map<String, Value>,
81    ) -> Result<(), LLMError> {
82        if let Some(output_format) = &request.output_format {
83            payload.insert("response_format".to_owned(), output_format.clone());
84        }
85        if let Some(parallel_tool_calls) = request.parallel_tool_calls
86            && payload.contains_key("tools")
87        {
88            payload.insert("parallel_tool_calls".to_owned(), Value::Bool(parallel_tool_calls));
89        }
90        Ok(())
91    }
92
93    fn validate(_core: &OpenAiCompatCore<Self>, request: &LLMRequest) -> Result<(), LLMError> {
94        validate_supported_models(request, Self::NAME, Self::KEY, Self::LISTED_MODELS)?;
95
96        if request.tools.as_ref().is_some_and(|tools| !tools.is_empty())
97            && request
98                .tool_choice
99                .as_ref()
100                .is_some_and(|choice| !matches!(choice, ToolChoice::Auto | ToolChoice::None))
101        {
102            return Err(LLMError::InvalidRequest {
103                message: "Meta AI Chat Completions supports only `tool_choice: auto` when tools are present".to_owned(),
104                metadata: None,
105            });
106        }
107
108        Ok(())
109    }
110}
111
112impl_openai_compat_provider!(MetaProvider, MetaSpec, {
113    fn supports_streaming(&self) -> bool {
114        true
115    }
116
117    fn supports_structured_output(&self, _model: &str) -> bool {
118        true
119    }
120
121    fn supports_vision(&self, _model: &str) -> bool {
122        true
123    }
124
125    fn supports_reasoning(&self, _model: &str) -> bool {
126        true
127    }
128
129    fn supports_reasoning_effort(&self, _model: &str) -> bool {
130        true
131    }
132
133    fn effective_context_size(&self, _model: &str) -> usize {
134        1_048_576
135    }
136});
137
138#[cfg(test)]
139mod tests {
140    use super::{MetaProvider, MetaSpec};
141    use crate::BackendKind;
142    use crate::provider::{LLMProvider, LLMRequest, Message, ToolChoice, ToolDefinition};
143    use crate::providers::openai_compat::OpenAiCompatSpec;
144    use std::sync::Arc;
145    use vtcode_config::constants::{models, urls};
146    use vtcode_config::types::ReasoningEffortLevel;
147
148    fn provider() -> MetaProvider {
149        MetaProvider::from_config(
150            Some("test-key".to_owned()),
151            Some(models::meta::DEFAULT_MODEL.to_owned()),
152            None,
153            None,
154            None,
155            None,
156            None,
157        )
158    }
159
160    fn request() -> LLMRequest {
161        LLMRequest {
162            messages: Arc::new(vec![Message::user("hello".to_owned())]),
163            model: models::meta::DEFAULT_MODEL.to_owned(),
164            max_tokens: Some(512),
165            temperature: Some(0.4),
166            top_p: Some(0.8),
167            stream: true,
168            ..Default::default()
169        }
170    }
171
172    #[test]
173    fn payload_omits_callable_tools_for_tool_choice_none() {
174        let provider = provider();
175        for stream in [false, true] {
176            let mut request = LLMRequest {
177                model: models::meta::DEFAULT_MODEL.to_owned(),
178                messages: vec![Message::user("Synthesize the saved evidence".to_owned())].into(),
179                tools: Some(Arc::new(vec![ToolDefinition::function(
180                    "lookup".to_owned(),
181                    "Look up a value".to_owned(),
182                    serde_json::json!({"type": "object"}),
183                )])),
184                tool_choice: Some(ToolChoice::None),
185                stream,
186                ..Default::default()
187            };
188            provider
189                .validate_request(&request)
190                .expect("none is supported by omitting tools");
191            let payload = provider.core.convert_request(&request).expect("payload");
192            assert!(payload.get("tools").is_none());
193            assert!(payload.get("tool_choice").is_none());
194            assert!(payload.get("tool_stream").is_none());
195            request.tool_choice = Some(ToolChoice::Auto);
196            let enabled = provider.core.convert_request(&request).expect("enabled payload");
197            assert_eq!(enabled["tools"][0]["function"]["name"], "lookup");
198            assert_eq!(enabled["tool_choice"], "auto");
199        }
200    }
201
202    #[test]
203    fn meta_uses_official_endpoint_and_backend_kind() {
204        let provider = provider();
205        assert_eq!(provider.core.base_url, urls::META_API_BASE);
206        assert_eq!(provider.core.api_key, "test-key");
207        assert_eq!(provider.backend_kind(), BackendKind::Meta);
208        assert_eq!(MetaSpec::API_KEY_ENV, "META_API_KEY");
209    }
210
211    #[test]
212    fn supported_models_include_all_official_meta_ids() {
213        let expected = models::meta::SUPPORTED_MODELS
214            .iter()
215            .map(|model| (*model).to_owned())
216            .collect::<Vec<_>>();
217        assert_eq!(MetaProvider::new("test-key".to_owned()).supported_models(), expected);
218    }
219
220    #[test]
221    fn payload_uses_meta_completion_fields() {
222        let payload = provider().core.convert_request(&request()).expect("payload should be valid");
223
224        assert_eq!(payload["model"], models::meta::DEFAULT_MODEL);
225        assert_eq!(payload["max_completion_tokens"], 512);
226        assert!((payload["temperature"].as_f64().expect("temperature should be numeric") - 0.4).abs() < 1e-6);
227        assert!((payload["top_p"].as_f64().expect("top_p should be numeric") - 0.8).abs() < 1e-6);
228        assert_eq!(payload["stream"], true);
229        assert!(payload.get("stream_options").is_none());
230    }
231
232    #[test]
233    fn reasoning_effort_maps_to_meta_values() {
234        for (effort, expected) in [
235            (ReasoningEffortLevel::Minimal, "minimal"),
236            (ReasoningEffortLevel::Low, "low"),
237            (ReasoningEffortLevel::Medium, "medium"),
238            (ReasoningEffortLevel::High, "high"),
239            (ReasoningEffortLevel::XHigh, "xhigh"),
240            (ReasoningEffortLevel::Max, "max"),
241        ] {
242            let mut request = request();
243            request.reasoning_effort = Some(effort);
244            let payload = provider().core.convert_request(&request).expect("payload should be valid");
245            assert_eq!(payload["reasoning_effort"], expected);
246        }
247
248        let mut request = request();
249        request.reasoning_effort = Some(ReasoningEffortLevel::None);
250        let payload = provider().core.convert_request(&request).expect("payload should be valid");
251        assert!(payload.get("reasoning_effort").is_none());
252    }
253
254    #[test]
255    fn structured_output_and_parallel_tools_are_forwarded() {
256        let mut request = request();
257        request.output_format = Some(serde_json::json!({
258            "type": "json_schema",
259            "json_schema": {"name": "answer", "schema": {"type": "object"}}
260        }));
261        request.parallel_tool_calls = Some(true);
262        request.tools = Some(Arc::new(vec![ToolDefinition::function(
263            "lookup".to_owned(),
264            "Look up a value".to_owned(),
265            serde_json::json!({"type": "object"}),
266        )]));
267        request.tool_choice = Some(ToolChoice::Auto);
268
269        let payload = provider().core.convert_request(&request).expect("payload should be valid");
270        assert_eq!(payload["response_format"]["type"], "json_schema");
271        assert_eq!(payload["parallel_tool_calls"], true);
272        assert_eq!(payload["tool_choice"], "auto");
273    }
274
275    #[test]
276    fn unsupported_tool_choice_is_rejected_when_tools_are_present() {
277        let mut request = request();
278        request.tools = Some(Arc::new(vec![ToolDefinition::function(
279            "lookup".to_owned(),
280            "Look up a value".to_owned(),
281            serde_json::json!({"type": "object"}),
282        )]));
283        request.tool_choice = Some(ToolChoice::Any);
284
285        let error = provider().validate_request(&request).expect_err("choice should be rejected");
286        assert!(error.to_string().contains("tool_choice"));
287    }
288}