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 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}