Skip to main content

vtcode_llm/providers/
evolink.rs

1use serde_json::{Map, Value};
2
3use crate::client::LLMClient;
4use crate::error_display;
5use crate::provider::{FinishReason, LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent};
6use vtcode_config::constants::{env_vars, models, urls};
7use vtcode_config::core::PromptCachingConfig;
8use vtcode_config::types::ReasoningEffortLevel;
9
10use super::common::{collect_history_system_directives, merge_system_prompt_with_history_directives};
11use super::error_handling::{format_network_error, handle_openai_http_error};
12use super::extract_reasoning_trace;
13use super::openai_compat::{OpenAiCompatCore, OpenAiCompatSpec};
14
15const PROVIDER_NAME: &str = "Evolink";
16const PROVIDER_KEY: &str = "evolink";
17const PRIMARY_API_KEY_ENV: &str = "EVOLINK_API_KEY";
18
19pub struct EvolinkSpec;
20
21/// Evolink's gateway expects bare upstream model names (e.g. `gpt-5.2`).
22/// The curated `ModelId` catalog namespaces entries as `evolink/<model>`, so
23/// strip that prefix before sending the request upstream.
24fn normalize(model: &str) -> &str {
25    model.trim().strip_prefix("evolink/").unwrap_or(model.trim())
26}
27
28fn evolink_reasoning(message: &Value, choice: &Value) -> Option<String> {
29    message
30        .get("reasoning")
31        .and_then(extract_reasoning_trace)
32        .or_else(|| message.get("reasoning_content").and_then(extract_reasoning_trace))
33        .or_else(|| choice.get("reasoning").and_then(extract_reasoning_trace))
34}
35
36fn reasoning_effort_value(effort: ReasoningEffortLevel) -> Option<&'static str> {
37    match effort {
38        ReasoningEffortLevel::None | ReasoningEffortLevel::Unknown => None,
39        ReasoningEffortLevel::Minimal | ReasoningEffortLevel::Low => Some("low"),
40        ReasoningEffortLevel::Medium => Some("medium"),
41        ReasoningEffortLevel::High | ReasoningEffortLevel::XHigh | ReasoningEffortLevel::Max => Some("high"),
42    }
43}
44
45impl OpenAiCompatSpec for EvolinkSpec {
46    const NAME: &'static str = PROVIDER_NAME;
47    const KEY: &'static str = PROVIDER_KEY;
48    const API_KEY_ENV: &'static str = PRIMARY_API_KEY_ENV;
49    const DEFAULT_MODEL: &'static str = models::evolink::DEFAULT_MODEL;
50    const DEFAULT_BASE_URL: &'static str = urls::EVOLINK_API_BASE;
51    const BASE_URL_ENV: Option<&'static str> = Some(env_vars::EVOLINK_BASE_URL);
52    const LISTED_MODELS: &'static [&'static str] = models::evolink::SUPPORTED_MODELS;
53    // Evolink is a gateway whose upstream catalog changes over time, so do
54    // not constrain requests to the curated `SUPPORTED_MODELS` list.
55    const VALIDATION_ALLOWLIST: Option<&'static [&'static str]> = None;
56
57    const STREAM_REASONING_FIELDS: &'static [&'static str] = &["reasoning", "reasoning_content"];
58    const RESPONSE_REASONING_EXTRACTOR: Option<super::openai_compat::ReasoningExtractor> = Some(evolink_reasoning);
59
60    fn resolve_api_key(api_key: Option<String>) -> String {
61        api_key
62            .or_else(|| std::env::var(Self::API_KEY_ENV).ok().filter(|key| !key.trim().is_empty()))
63            .unwrap_or_default()
64    }
65
66    /// Evolink's gateway expects bare upstream model names (e.g. `gpt-5.2`).
67    /// The curated `ModelId` catalog namespaces entries as `evolink/<model>`, so
68    /// strip that prefix before sending the request upstream.
69    fn normalize_model(model: String) -> String {
70        normalize(&model).to_string()
71    }
72
73    fn prompt_cache_enabled(_prompt_cache: Option<&PromptCachingConfig>) -> bool {
74        false
75    }
76
77    fn insert_reasoning(
78        _core: &OpenAiCompatCore<Self>,
79        request: &LLMRequest,
80        payload: &mut Map<String, Value>,
81    ) -> Result<(), LLMError> {
82        if let Some(effort) = request.reasoning_effort
83            && let Some(mapped) = reasoning_effort_value(effort)
84        {
85            payload.insert("reasoning_effort".to_owned(), Value::String(mapped.to_string()));
86        }
87        Ok(())
88    }
89}
90
91pub struct EvolinkProvider {
92    core: OpenAiCompatCore<EvolinkSpec>,
93}
94
95impl EvolinkProvider {
96    fn new(api_key: String) -> Self {
97        Self::with_model(api_key, models::evolink::DEFAULT_MODEL.to_string())
98    }
99
100    fn with_model(api_key: String, model: String) -> Self {
101        Self { core: OpenAiCompatCore::direct(api_key, model) }
102    }
103
104    pub fn new_with_client(
105        api_key: String,
106        model: String,
107        http_client: reqwest::Client,
108        base_url: String,
109        _timeouts: vtcode_config::TimeoutsConfig,
110    ) -> Self {
111        Self {
112            core: OpenAiCompatCore::from_parts(api_key, model, http_client, base_url),
113        }
114    }
115
116    pub fn from_config(
117        api_key: Option<String>,
118        model: Option<String>,
119        base_url: Option<String>,
120        _prompt_cache: Option<PromptCachingConfig>,
121        timeouts: Option<vtcode_config::TimeoutsConfig>,
122        _anthropic: Option<vtcode_config::core::AnthropicConfig>,
123        model_behavior: Option<vtcode_config::core::ModelConfig>,
124    ) -> Self {
125        Self {
126            core: OpenAiCompatCore::from_config(api_key, model, base_url, _prompt_cache, timeouts, model_behavior),
127        }
128    }
129
130    fn is_anthropic_model(model: &str) -> bool {
131        models::evolink::is_anthropic_format(model)
132    }
133
134    fn convert_to_anthropic_format(&self, request: &LLMRequest) -> Result<Value, LLMError> {
135        let mut payload = Map::with_capacity(8);
136        let model = normalize(&request.model).to_string();
137        payload.insert("model".to_owned(), Value::String(model));
138
139        // Anthropic uses a top-level `system` field, so promote the same
140        // canonical history directives that the direct Anthropic adapter uses.
141        let history_system_directives = collect_history_system_directives(request);
142        let system_prompt = merge_system_prompt_with_history_directives(
143            request.system_prompt.as_deref(),
144            &history_system_directives,
145            "[History Directives]",
146        );
147        if let Some(system_prompt) = system_prompt {
148            let trimmed = system_prompt.trim();
149            if !trimmed.is_empty() {
150                payload.insert("system".to_owned(), Value::String(trimmed.to_string()));
151            }
152        }
153
154        // Convert messages to Anthropic format (user/assistant only, no system)
155        let anthropic_messages: Vec<Value> = request
156            .messages
157            .iter()
158            .filter(|msg| msg.role != crate::provider::MessageRole::System)
159            .map(|msg| {
160                let role = match msg.role {
161                    crate::provider::MessageRole::User => "user",
162                    crate::provider::MessageRole::Assistant => "assistant",
163                    _ => "user",
164                };
165                serde_json::json!({
166                    "role": role,
167                    "content": msg.content.as_text()
168                })
169            })
170            .collect();
171        payload.insert("messages".to_owned(), Value::Array(anthropic_messages));
172
173        let max_tokens = request.max_tokens.unwrap_or(8192);
174        payload.insert("max_tokens".to_owned(), Value::Number(serde_json::Number::from(max_tokens as u64)));
175
176        if let Some(temperature) = request.temperature {
177            payload.insert("temperature".to_owned(), Value::Number(super::common::float_to_json_number(temperature)?));
178        }
179
180        if request.stream {
181            payload.insert("stream".to_owned(), Value::Bool(true));
182        }
183
184        Ok(Value::Object(payload))
185    }
186
187    fn parse_anthropic_response(response_json: Value, model: String) -> Result<LLMResponse, LLMError> {
188        let content = response_json.get("content").and_then(|c| c.as_array()).map(|blocks| {
189            blocks
190                .iter()
191                .filter_map(|block| {
192                    if block.get("type").and_then(|t| t.as_str()) == Some("text") {
193                        block.get("text").and_then(|t| t.as_str()).map(String::from)
194                    } else {
195                        None
196                    }
197                })
198                .collect::<Vec<_>>()
199                .join("")
200        });
201
202        let usage = response_json.get("usage").map(|u| {
203            let prompt_tokens = u.get("input_tokens").and_then(|t| t.as_u64()).unwrap_or(0) as u32;
204            let completion_tokens = u.get("output_tokens").and_then(|t| t.as_u64()).unwrap_or(0) as u32;
205            crate::provider::Usage {
206                prompt_tokens,
207                completion_tokens,
208                total_tokens: prompt_tokens + completion_tokens,
209                cached_prompt_tokens: u.get("cache_read_input_tokens").and_then(|t| t.as_u64()).map(|v| v as u32),
210                cache_creation_tokens: u.get("cache_creation_input_tokens").and_then(|t| t.as_u64()).map(|v| v as u32),
211                cache_read_tokens: None,
212                iterations: None,
213            }
214        });
215
216        let finish_reason = match response_json.get("stop_reason").and_then(|r| r.as_str()) {
217            Some("end_turn") | Some("stop_sequence") => FinishReason::Stop,
218            Some("max_tokens") => FinishReason::Length,
219            Some("tool_use") => FinishReason::ToolCalls,
220            _ => FinishReason::Stop,
221        };
222
223        Ok(LLMResponse {
224            content,
225            tool_calls: None,
226            model,
227            usage,
228            finish_reason,
229            reasoning: None,
230            reasoning_details: None,
231            tool_references: Vec::new(),
232            request_id: response_json.get("id").and_then(|id| id.as_str()).map(String::from),
233            organization_id: None,
234            compaction: None,
235        })
236    }
237
238    async fn generate_anthropic(&self, mut request: LLMRequest, model: String) -> Result<LLMResponse, LLMError> {
239        request.stream = false;
240        let payload = self.convert_to_anthropic_format(&request)?;
241        let url = format!("{}/messages", self.core.base_url.trim_end_matches('/'));
242
243        let response = self
244            .core
245            .http_client
246            .post(&url)
247            .bearer_auth(&self.core.api_key)
248            .header("anthropic-version", "2023-06-01")
249            .json(&payload)
250            .send()
251            .await
252            .map_err(|error| format_network_error(PROVIDER_NAME, &error))?;
253
254        let response = handle_openai_http_error(response, PROVIDER_NAME, PRIMARY_API_KEY_ENV).await?;
255
256        let response_json: Value = response.json().await.map_err(|error| LLMError::Provider {
257            message: error_display::format_llm_error(
258                PROVIDER_NAME,
259                &format!("failed to parse Anthropic response: {error}"),
260            ),
261            metadata: None,
262        })?;
263
264        Self::parse_anthropic_response(response_json, model)
265    }
266}
267
268#[async_trait::async_trait]
269impl LLMProvider for EvolinkProvider {
270    fn name(&self) -> &str {
271        EvolinkSpec::KEY
272    }
273
274    fn supports_streaming(&self) -> bool {
275        true
276    }
277
278    fn supports_non_streaming(&self, _model: &str) -> bool {
279        // Pinned so the stream-timeout fallback cannot silently regress.
280        true
281    }
282
283    fn supports_tools(&self, _model: &str) -> bool {
284        true
285    }
286
287    fn supports_structured_output(&self, _model: &str) -> bool {
288        true
289    }
290
291    fn supports_vision(&self, _model: &str) -> bool {
292        true
293    }
294
295    fn supports_reasoning(&self, model: &str) -> bool {
296        let requested = if model.trim().is_empty() {
297            self.core.model.as_str()
298        } else {
299            normalize(model)
300        };
301
302        self.core
303            .model_behavior
304            .as_ref()
305            .and_then(|behavior| behavior.model_supports_reasoning)
306            .unwrap_or(false)
307            || models::evolink::REASONING_MODELS.contains(&requested)
308    }
309
310    fn supports_reasoning_effort(&self, model: &str) -> bool {
311        let requested = if model.trim().is_empty() {
312            self.core.model.as_str()
313        } else {
314            normalize(model)
315        };
316
317        self.core
318            .model_behavior
319            .as_ref()
320            .and_then(|behavior| behavior.model_supports_reasoning_effort)
321            .unwrap_or(false)
322            || models::evolink::REASONING_MODELS.contains(&requested)
323    }
324
325    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
326        self.core.prepare(&mut request);
327        let model = request.model.clone();
328
329        if Self::is_anthropic_model(&model) {
330            return self.generate_anthropic(request, model).await;
331        }
332
333        self.core.generate_prepared(request).await
334    }
335
336    async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
337        self.core.prepare(&mut request);
338        self.validate_request(&request)?;
339        let model = request.model.clone();
340
341        // Anthropic models: fall back to non-streaming via generate_anthropic
342        if Self::is_anthropic_model(&model) {
343            request.stream = false;
344            let response = self.generate_anthropic(request, model).await?;
345            let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Result<LLMStreamEvent, LLMError>>();
346            let _ = tx.send(Ok(LLMStreamEvent::Completed { response: Box::new(response) }));
347            let stream = async_stream::try_stream! {
348                let mut receiver = rx;
349                while let Some(event) = receiver.recv().await {
350                    yield event?;
351                }
352            };
353            return Ok(Box::pin(stream));
354        }
355
356        request.stream = true;
357        self.core.stream_prepared(request).await
358    }
359
360    fn supported_models(&self) -> Vec<String> {
361        self.core.supported_models()
362    }
363
364    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
365        // Evolink is a gateway whose upstream catalog changes over time, so do
366        // not constrain requests to the curated `SUPPORTED_MODELS` list (see
367        // `EvolinkSpec::VALIDATION_ALLOWLIST`).
368        self.core.validate(request)
369    }
370}
371
372#[async_trait::async_trait]
373impl LLMClient for EvolinkProvider {
374    async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
375        let request = super::common::make_default_request(prompt, &self.core.model);
376        Ok(LLMProvider::generate(self, request).await?)
377    }
378
379    fn model_id(&self) -> &str {
380        &self.core.model
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::EvolinkProvider;
387    use crate::provider::{LLMRequest, Message, ToolChoice};
388    use std::sync::Arc;
389    use vtcode_config::constants::{models, urls};
390    use vtcode_config::types::ReasoningEffortLevel;
391
392    #[test]
393    fn normalizes_namespaced_model_for_wire() {
394        let provider = EvolinkProvider::with_model("test-key".to_string(), "evolink/gpt-5.6".to_string());
395        assert_eq!(provider.model_id_for_test(), models::evolink::GPT_5_6);
396    }
397
398    #[test]
399    fn defaults_to_direct_base_url() {
400        let provider = EvolinkProvider::new("test-key".to_string());
401        assert_eq!(provider.base_url_for_test(), urls::EVOLINK_API_BASE);
402    }
403
404    #[test]
405    fn payload_strips_prefix_and_maps_reasoning_effort() {
406        let provider = EvolinkProvider::new("test-key".to_string());
407        let mut request = LLMRequest {
408            model: "evolink/gpt-5.6".to_string(),
409            messages: vec![Message::user("hello".to_string())].into(),
410            reasoning_effort: Some(ReasoningEffortLevel::High),
411            ..Default::default()
412        };
413        provider.core.prepare(&mut request);
414        let payload = provider.core.convert_request(&request).expect("payload should be valid");
415
416        assert_eq!(payload.get("model").and_then(|value| value.as_str()), Some(models::evolink::GPT_5_6));
417        assert_eq!(payload.get("reasoning_effort").and_then(|value| value.as_str()), Some("high"));
418        assert!(payload.get("temperature").is_none());
419    }
420
421    #[test]
422    fn golden_payload_basic_shape() {
423        let provider = EvolinkProvider::new("test-key".to_string());
424        let mut request = LLMRequest {
425            model: "evolink/gpt-5.6".to_string(),
426            messages: vec![Message::user("hello".to_string())].into(),
427            system_prompt: Some(Arc::from("system guidance")),
428            max_tokens: Some(512),
429            temperature: Some(0.5),
430            top_p: Some(0.25),
431            stream: true,
432            tool_choice: Some(ToolChoice::Auto),
433            metadata: Some(serde_json::json!({"user_id": "user-42"})),
434            ..Default::default()
435        };
436        provider.core.prepare(&mut request);
437        let payload = provider.core.convert_request(&request).expect("payload should be valid");
438
439        assert_eq!(payload.get("model").and_then(|value| value.as_str()), Some(models::evolink::GPT_5_6));
440        let messages = payload.get("messages").and_then(|v| v.as_array()).unwrap();
441        assert_eq!(messages.len(), 2);
442        assert_eq!(messages[0]["role"], "system");
443        assert_eq!(messages[0]["content"], "system guidance");
444        assert_eq!(payload["max_tokens"], 512);
445        assert_eq!(payload["temperature"], 0.5);
446        assert_eq!(payload["top_p"], 0.25);
447        assert_eq!(payload["stream"], true);
448        assert!(payload.get("stream_options").is_none());
449        assert!(payload.get("user_id").is_none());
450        assert_eq!(payload["tool_choice"], "auto");
451        assert!(payload.get("reasoning_effort").is_none());
452    }
453
454    #[test]
455    fn golden_anthropic_payload_shape() {
456        let provider = EvolinkProvider::new("test-key".to_string());
457        let payload = provider
458            .convert_to_anthropic_format(&LLMRequest {
459                model: "evolink/claude-x".to_string(),
460                messages: vec![Message::user("hello".to_string())].into(),
461                system_prompt: Some(Arc::from("system guidance")),
462                temperature: Some(0.5),
463                stream: false,
464                ..Default::default()
465            })
466            .expect("payload should be valid");
467
468        assert_eq!(payload["system"], "system guidance");
469        let messages = payload.get("messages").and_then(|v| v.as_array()).unwrap();
470        assert_eq!(messages.len(), 1);
471        assert_eq!(messages[0]["role"], "user");
472        assert_eq!(payload["max_tokens"], 8192);
473        assert_eq!(payload["temperature"], 0.5);
474        assert!(payload.get("stream").is_none());
475    }
476
477    #[test]
478    fn anthropic_payload_promotes_history_system_directives() {
479        let provider = EvolinkProvider::new("test-key".to_string());
480        let payload = provider
481            .convert_to_anthropic_format(&LLMRequest {
482                model: "evolink/claude-x".to_string(),
483                messages: vec![
484                    Message::user("run a command".to_string()),
485                    Message::system("Only you see that command's output".to_string()),
486                    Message::user("continue".to_string()),
487                ]
488                .into(),
489                system_prompt: Some(Arc::from("system guidance")),
490                ..Default::default()
491            })
492            .expect("payload should promote history directives");
493
494        assert!(payload["system"].as_str().is_some_and(|system| {
495            system.contains("system guidance") && system.contains("Only you see that command's output")
496        }));
497        let messages = payload.get("messages").and_then(|v| v.as_array()).unwrap();
498        assert_eq!(messages.len(), 2);
499        assert!(messages.iter().all(|message| message["role"] != "system"));
500    }
501
502    impl EvolinkProvider {
503        fn model_id_for_test(&self) -> &str {
504            &self.core.model
505        }
506
507        fn base_url_for_test(&self) -> &str {
508            &self.core.base_url
509        }
510    }
511}