1use vtcode_config::constants::{env_vars, models, urls};
2
3use super::openai_compat::{OpenAiCompatCore, OpenAiCompatSpec, impl_openai_compat_provider};
4
5pub struct VercelSpec;
6
7impl OpenAiCompatSpec for VercelSpec {
8 const NAME: &'static str = "Vercel AI Gateway";
9 const KEY: &'static str = "vercel";
10 const API_KEY_ENV: &'static str = "AI_GATEWAY_API_KEY";
11 const DEFAULT_MODEL: &'static str = models::vercel::DEFAULT_MODEL;
12 const DEFAULT_BASE_URL: &'static str = urls::VERCEL_AI_GATEWAY_API_BASE;
13 const BASE_URL_ENV: Option<&'static str> = Some(env_vars::VERCEL_AI_GATEWAY_BASE_URL);
14 const LISTED_MODELS: &'static [&'static str] = models::vercel::SUPPORTED_MODELS;
15 const VALIDATION_ALLOWLIST: Option<&'static [&'static str]> = None;
18
19 const SUPPRESS_SAMPLING_WHEN_REASONING: bool = false;
20 const STREAM_OPTIONS_INCLUDE_USAGE: bool = true;
21 const STREAM_REASONING_FIELDS: &'static [&'static str] = &["reasoning", "reasoning_content"];
24
25 fn resolve_api_key(api_key: Option<String>) -> String {
26 api_key
27 .or_else(|| std::env::var(Self::API_KEY_ENV).ok().filter(|key| !key.trim().is_empty()))
28 .unwrap_or_default()
29 }
30}
31
32impl VercelProvider {
33 fn vercel_compact_model(&self, model: &str) -> bool {
39 let resolved = if model.trim().is_empty() {
40 self.core.model.as_str()
41 } else {
42 model
43 };
44 resolved.starts_with("openai/") && self.core.base_url.contains("vercel.sh")
45 }
46
47 fn compact_client(&self, model: &str) -> crate::providers::openresponses::OpenResponsesProvider {
48 crate::providers::openresponses::OpenResponsesProvider::compact_endpoint_client(
49 &self.core.model,
50 &self.core.base_url,
51 &self.core.api_key,
52 model,
53 )
54 }
55}
56
57impl_openai_compat_provider!(VercelProvider, VercelSpec, {
58 fn supports_streaming(&self) -> bool {
59 true
60 }
61
62 fn supports_structured_output(&self, _model: &str) -> bool {
63 true
64 }
65
66 fn supports_reasoning(&self, model: &str) -> bool {
67 use vtcode_config::constants::models;
68 !models::vercel::NON_REASONING_MODELS.contains(&model)
69 }
70
71 fn effective_context_size(&self, model: &str) -> usize {
72 crate::provider::catalog_context_window("vercel", model, 1_000_000)
73 }
74
75 fn supports_responses_compaction(&self, model: &str) -> bool {
76 self.vercel_compact_model(model)
77 }
78
79 fn supports_manual_openai_compaction(&self, model: &str) -> bool {
80 self.vercel_compact_model(model)
81 }
82
83 async fn compact_history(
84 &self,
85 model: &str,
86 history: &[crate::provider::Message],
87 ) -> Result<Vec<crate::provider::Message>, crate::provider::LLMError> {
88 if !self.vercel_compact_model(model) {
89 return Err(crate::provider::LLMError::Provider {
90 message:
91 "Vercel AI Gateway compaction is only supported for OpenAI-routed models on the gateway endpoint"
92 .to_string(),
93 metadata: None,
94 });
95 }
96 self.compact_client(model).compact_history_request(model, history).await
97 }
98
99 async fn compact_history_with_options(
100 &self,
101 model: &str,
102 history: &[crate::provider::Message],
103 _options: &crate::provider::ResponsesCompactionOptions,
104 ) -> Result<Vec<crate::provider::Message>, crate::provider::LLMError> {
105 self.compact_history(model, history).await
106 }
107});
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use crate::provider::{LLMRequest, Message, ToolChoice};
113 use std::sync::Arc;
114 use vtcode_config::types::ReasoningEffortLevel;
115
116 fn provider() -> VercelProvider {
117 VercelProvider::from_config(
118 Some("test-key".to_string()),
119 Some(models::vercel::ANTHROPIC_CLAUDE_SONNET_5.to_string()),
120 Some("https://example.test/v1".to_string()),
121 None,
122 None,
123 None,
124 None,
125 )
126 }
127
128 fn base_request() -> LLMRequest {
129 LLMRequest {
130 messages: vec![Message::user("hello".to_string())].into(),
131 system_prompt: Some(Arc::from("system guidance")),
132 model: models::vercel::ANTHROPIC_CLAUDE_SONNET_5.to_string(),
133 max_tokens: Some(512),
134 temperature: Some(0.5),
135 stream: true,
136 tool_choice: Some(ToolChoice::Auto),
137 ..Default::default()
138 }
139 }
140
141 #[test]
142 fn golden_payload_basic_shape() {
143 let payload = provider().core.convert_request(&base_request()).unwrap();
144
145 assert_eq!(payload["model"], models::vercel::ANTHROPIC_CLAUDE_SONNET_5);
146 let messages = payload["messages"].as_array().unwrap();
147 assert_eq!(messages.len(), 2);
148 assert_eq!(messages[0]["role"], "system");
149 assert_eq!(messages[0]["content"], "system guidance");
150 assert_eq!(messages[1]["role"], "user");
151 assert_eq!(messages[1]["content"], "hello");
152 assert_eq!(payload["max_tokens"], 512);
153 assert_eq!(payload["temperature"], 0.5);
154 assert_eq!(payload["stream"], true);
155 assert_eq!(payload["stream_options"]["include_usage"], true);
156 assert_eq!(payload["tool_choice"], "auto");
157 }
158
159 #[test]
160 fn golden_payload_reasoning_keeps_sampling() {
161 let mut request = base_request();
162 request.reasoning_effort = Some(ReasoningEffortLevel::High);
163 let payload = provider().core.convert_request(&request).unwrap();
164 assert_eq!(payload["temperature"], 0.5);
165 assert!(payload.get("reasoning").is_none());
166 }
167
168 #[test]
169 fn golden_payload_omits_empty_system_prompt() {
170 let mut request = base_request();
171 request.system_prompt = Some(Arc::from(" "));
172 request.stream = false;
173 let payload = provider().core.convert_request(&request).unwrap();
174 let messages = payload["messages"].as_array().unwrap();
175 assert_eq!(messages.len(), 1);
176 assert_eq!(messages[0]["role"], "user");
177 assert!(payload.get("stream").is_none());
178 assert!(payload.get("stream_options").is_none());
179 }
180
181 #[test]
182 fn gateway_ids_are_forwarded_verbatim() {
183 let model = "some-vendor/unlisted-model";
184 let provider =
185 VercelProvider::from_config(Some("k".to_string()), Some(model.to_string()), None, None, None, None, None);
186 assert_eq!(provider.core.model, model);
187 assert_eq!(provider.core.base_url, urls::VERCEL_AI_GATEWAY_API_BASE);
188 }
189
190 #[test]
191 fn compaction_support_is_openai_gateway_routes_only() {
192 use crate::provider::LLMProvider;
193
194 let gateway = VercelProvider::from_config(
196 Some("k".to_string()),
197 Some("openai/gpt-6-astra".to_string()),
198 None,
199 None,
200 None,
201 None,
202 None,
203 );
204 assert!(gateway.supports_responses_compaction("openai/gpt-6-astra"));
205 assert!(gateway.supports_manual_openai_compaction("openai/gpt-6-astra"));
206 assert!(!gateway.supports_responses_compaction("anthropic/claude-sonnet-5"));
207 assert!(!gateway.supports_manual_openai_compaction("anthropic/claude-sonnet-5"));
208 assert!(!gateway.supports_native_inline_compaction("openai/gpt-6-astra"));
209
210 let custom = provider();
213 assert!(!custom.supports_manual_openai_compaction("openai/gpt-6-astra"));
214 }
215
216 #[tokio::test]
217 async fn compact_history_posts_to_gateway_compact_endpoint() {
218 use wiremock::matchers::{method, path};
219 use wiremock::{Mock, MockServer, ResponseTemplate};
220
221 let server = MockServer::start().await;
222 Mock::given(method("POST"))
223 .and(path("/v1/responses/compact"))
224 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
225 "id": "resp_compact_1",
226 "object": "response.compaction",
227 "created_at": 1756800000,
228 "output": [
229 {
230 "id": "msg_000",
231 "type": "message",
232 "status": "completed",
233 "role": "user",
234 "content": [{ "type": "input_text", "text": "Refactor the auth module." }]
235 },
236 {
237 "id": "cmp_001",
238 "type": "compaction",
239 "encrypted_content": "gAAAAABpM0Yj"
240 }
241 ]
242 })))
243 .mount(&server)
244 .await;
245
246 let provider = VercelProvider::from_config(
249 Some("test-key".to_string()),
250 Some("openai/gpt-6-astra".to_string()),
251 Some(format!("{}/v1", server.uri())),
252 None,
253 None,
254 None,
255 None,
256 );
257 let history = vec![Message::user("Refactor the auth module.".to_string())];
258 let compacted = provider
259 .compact_client("openai/gpt-6-astra")
260 .compact_history_request("openai/gpt-6-astra", &history)
261 .await
262 .expect("gateway compaction should succeed");
263 assert!(!compacted.is_empty());
264 assert!(
265 compacted
266 .iter()
267 .any(|message| message.content.as_text().contains("Refactor the auth module.")),
268 "retained gateway input must survive compaction"
269 );
270 }
271
272 #[tokio::test]
273 async fn compact_history_rejects_non_openai_routes() {
274 use crate::provider::LLMProvider;
275
276 let provider = provider();
277 let history = vec![Message::user("hello".to_string())];
278 provider
279 .compact_history("anthropic/claude-sonnet-5", &history)
280 .await
281 .expect_err("non-OpenAI gateway routes must stay on local compaction");
282 }
283}