1use 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
11pub 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 .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 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}