Skip to main content

vtcode_llm/providers/
vercel.rs

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    // The gateway routes hundreds of models that change independently of this
16    // catalog, so only request shape is validated.
17    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    // The gateway forwards OpenAI-style reasoning payloads in both fields
22    // depending on the upstream vendor.
23    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    /// AI Gateway serves OpenAI's standalone compaction endpoint
34    /// (`POST /v1/responses/compact`, forwarded to OpenAI unchanged apart from
35    /// the model ID), but only for OpenAI-routed models on the gateway's own
36    /// endpoint. Other routes and custom base URLs stay on the universal local
37    /// summarization fallback.
38    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        // Default gateway endpoint: OpenAI-routed models compact natively.
195        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        // Custom base URLs stay on local compaction even for OpenAI-routed
211        // models: the compact endpoint only exists on the gateway itself.
212        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        // The host gate only passes on the gateway endpoint, so the transport
247        // is exercised through the compact client directly against the mock.
248        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}