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        // Meta Chat Completions defaults to `auto`; only emit the documented
52        // supported choice when callers explicitly select it.
53        if request.tools.as_ref().is_some_and(|tools| !tools.is_empty())
54            && matches!(request.tool_choice, Some(ToolChoice::Auto))
55        {
56            payload.insert("tool_choice".to_owned(), Value::String("auto".to_owned()));
57        }
58    }
59
60    fn insert_reasoning(
61        _core: &OpenAiCompatCore<Self>,
62        request: &LLMRequest,
63        payload: &mut Map<String, Value>,
64    ) -> Result<(), LLMError> {
65        if let Some(effort) = request.reasoning_effort
66            && let Some(value) = reasoning_effort_value(effort)
67        {
68            payload.insert("reasoning_effort".to_owned(), Value::String(value.to_owned()));
69        }
70        Ok(())
71    }
72
73    fn finish_payload(
74        _core: &OpenAiCompatCore<Self>,
75        request: &LLMRequest,
76        payload: &mut Map<String, Value>,
77    ) -> Result<(), LLMError> {
78        if let Some(output_format) = &request.output_format {
79            payload.insert("response_format".to_owned(), output_format.clone());
80        }
81        if let Some(parallel_tool_calls) = request.parallel_tool_calls
82            && request.tools.as_ref().is_some_and(|tools| !tools.is_empty())
83        {
84            payload.insert("parallel_tool_calls".to_owned(), Value::Bool(parallel_tool_calls));
85        }
86        Ok(())
87    }
88
89    fn validate(_core: &OpenAiCompatCore<Self>, request: &LLMRequest) -> Result<(), LLMError> {
90        validate_supported_models(request, Self::NAME, Self::KEY, Self::LISTED_MODELS)?;
91
92        if request.tools.as_ref().is_some_and(|tools| !tools.is_empty())
93            && request
94                .tool_choice
95                .as_ref()
96                .is_some_and(|choice| !matches!(choice, ToolChoice::Auto))
97        {
98            return Err(LLMError::InvalidRequest {
99                message: "Meta AI Chat Completions supports only `tool_choice: auto` when tools are present".to_owned(),
100                metadata: None,
101            });
102        }
103
104        Ok(())
105    }
106}
107
108impl_openai_compat_provider!(MetaProvider, MetaSpec, {
109    fn supports_streaming(&self) -> bool {
110        true
111    }
112
113    fn supports_structured_output(&self, _model: &str) -> bool {
114        true
115    }
116
117    fn supports_vision(&self, _model: &str) -> bool {
118        true
119    }
120
121    fn supports_reasoning(&self, _model: &str) -> bool {
122        true
123    }
124
125    fn supports_reasoning_effort(&self, _model: &str) -> bool {
126        true
127    }
128
129    fn effective_context_size(&self, _model: &str) -> usize {
130        1_048_576
131    }
132});
133
134#[cfg(test)]
135mod tests {
136    use super::{MetaProvider, MetaSpec};
137    use crate::BackendKind;
138    use crate::provider::{LLMProvider, LLMRequest, Message, ToolChoice, ToolDefinition};
139    use crate::providers::openai_compat::OpenAiCompatSpec;
140    use std::sync::Arc;
141    use vtcode_config::constants::{models, urls};
142    use vtcode_config::types::ReasoningEffortLevel;
143
144    fn provider() -> MetaProvider {
145        MetaProvider::from_config(
146            Some("test-key".to_owned()),
147            Some(models::meta::DEFAULT_MODEL.to_owned()),
148            None,
149            None,
150            None,
151            None,
152            None,
153        )
154    }
155
156    fn request() -> LLMRequest {
157        LLMRequest {
158            messages: Arc::new(vec![Message::user("hello".to_owned())]),
159            model: models::meta::DEFAULT_MODEL.to_owned(),
160            max_tokens: Some(512),
161            temperature: Some(0.4),
162            top_p: Some(0.8),
163            stream: true,
164            ..Default::default()
165        }
166    }
167
168    #[test]
169    fn meta_uses_official_endpoint_and_backend_kind() {
170        let provider = provider();
171        assert_eq!(provider.core.base_url, urls::META_API_BASE);
172        assert_eq!(provider.core.api_key, "test-key");
173        assert_eq!(provider.backend_kind(), BackendKind::Meta);
174        assert_eq!(MetaSpec::API_KEY_ENV, "META_API_KEY");
175    }
176
177    #[test]
178    fn supported_models_include_all_official_meta_ids() {
179        let expected = models::meta::SUPPORTED_MODELS
180            .iter()
181            .map(|model| (*model).to_owned())
182            .collect::<Vec<_>>();
183        assert_eq!(MetaProvider::new("test-key".to_owned()).supported_models(), expected);
184    }
185
186    #[test]
187    fn payload_uses_meta_completion_fields() {
188        let payload = provider().core.convert_request(&request()).expect("payload should be valid");
189
190        assert_eq!(payload["model"], models::meta::DEFAULT_MODEL);
191        assert_eq!(payload["max_completion_tokens"], 512);
192        assert!((payload["temperature"].as_f64().expect("temperature should be numeric") - 0.4).abs() < 1e-6);
193        assert!((payload["top_p"].as_f64().expect("top_p should be numeric") - 0.8).abs() < 1e-6);
194        assert_eq!(payload["stream"], true);
195        assert!(payload.get("stream_options").is_none());
196    }
197
198    #[test]
199    fn reasoning_effort_maps_to_meta_values() {
200        for (effort, expected) in [
201            (ReasoningEffortLevel::Minimal, "minimal"),
202            (ReasoningEffortLevel::Low, "low"),
203            (ReasoningEffortLevel::Medium, "medium"),
204            (ReasoningEffortLevel::High, "high"),
205            (ReasoningEffortLevel::XHigh, "xhigh"),
206            (ReasoningEffortLevel::Max, "max"),
207        ] {
208            let mut request = request();
209            request.reasoning_effort = Some(effort);
210            let payload = provider().core.convert_request(&request).expect("payload should be valid");
211            assert_eq!(payload["reasoning_effort"], expected);
212        }
213
214        let mut request = request();
215        request.reasoning_effort = Some(ReasoningEffortLevel::None);
216        let payload = provider().core.convert_request(&request).expect("payload should be valid");
217        assert!(payload.get("reasoning_effort").is_none());
218    }
219
220    #[test]
221    fn structured_output_and_parallel_tools_are_forwarded() {
222        let mut request = request();
223        request.output_format = Some(serde_json::json!({
224            "type": "json_schema",
225            "json_schema": {"name": "answer", "schema": {"type": "object"}}
226        }));
227        request.parallel_tool_calls = Some(true);
228        request.tools = Some(Arc::new(vec![ToolDefinition::function(
229            "lookup".to_owned(),
230            "Look up a value".to_owned(),
231            serde_json::json!({"type": "object"}),
232        )]));
233        request.tool_choice = Some(ToolChoice::Auto);
234
235        let payload = provider().core.convert_request(&request).expect("payload should be valid");
236        assert_eq!(payload["response_format"]["type"], "json_schema");
237        assert_eq!(payload["parallel_tool_calls"], true);
238        assert_eq!(payload["tool_choice"], "auto");
239    }
240
241    #[test]
242    fn unsupported_tool_choice_is_rejected_when_tools_are_present() {
243        let mut request = request();
244        request.tools = Some(Arc::new(vec![ToolDefinition::function(
245            "lookup".to_owned(),
246            "Look up a value".to_owned(),
247            serde_json::json!({"type": "object"}),
248        )]));
249        request.tool_choice = Some(ToolChoice::Any);
250
251        let error = provider().validate_request(&request).expect_err("choice should be rejected");
252        assert!(error.to_string().contains("tool_choice"));
253    }
254}