Skip to main content

vtcode_core/llm/providers/
evolink.rs

1use async_stream::try_stream;
2use async_trait::async_trait;
3use reqwest::Client as HttpClient;
4use serde_json::{Map, Value};
5
6use crate::config::TimeoutsConfig;
7use crate::config::constants::{env_vars, models, urls};
8use crate::config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
9use crate::config::types::ReasoningEffortLevel;
10use crate::llm::client::LLMClient;
11use crate::llm::error_display;
12use crate::llm::provider::{
13    FinishReason, LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent,
14};
15
16use super::common::{
17    map_finish_reason_common, override_base_url, parse_response_openai_format, resolve_model,
18    serialize_messages_openai_format, serialize_tools_openai_format, validate_request_common,
19};
20use super::error_handling::handle_openai_http_error;
21use super::extract_reasoning_trace;
22
23const PROVIDER_NAME: &str = "Evolink";
24const PROVIDER_KEY: &str = "evolink";
25const PRIMARY_API_KEY_ENV: &str = "EVOLINK_API_KEY";
26
27pub struct EvolinkProvider {
28    api_key: String,
29    http_client: HttpClient,
30    base_url: String,
31    model: String,
32    model_behavior: Option<ModelConfig>,
33}
34
35impl EvolinkProvider {
36    /// Evolink's gateway expects bare upstream model names (e.g. `gpt-5.2`).
37    /// The curated `ModelId` catalog namespaces entries as `evolink/<model>`, so
38    /// strip that prefix before sending the request upstream.
39    fn normalize_model(model: &str) -> &str {
40        model
41            .trim()
42            .strip_prefix("evolink/")
43            .unwrap_or(model.trim())
44    }
45
46    pub fn new(api_key: String) -> Self {
47        Self::with_model_internal(
48            api_key,
49            models::evolink::DEFAULT_MODEL.to_string(),
50            None,
51            None,
52            None,
53        )
54    }
55
56    pub fn with_model(api_key: String, model: String) -> Self {
57        Self::with_model_internal(api_key, model, None, None, None)
58    }
59
60    pub fn new_with_client(
61        api_key: String,
62        model: String,
63        http_client: reqwest::Client,
64        base_url: String,
65        _timeouts: TimeoutsConfig,
66    ) -> Self {
67        Self {
68            api_key,
69            http_client,
70            base_url,
71            model: Self::normalize_model(&model).to_string(),
72            model_behavior: None,
73        }
74    }
75
76    pub fn from_config(
77        api_key: Option<String>,
78        model: Option<String>,
79        base_url: Option<String>,
80        _prompt_cache: Option<PromptCachingConfig>,
81        timeouts: Option<TimeoutsConfig>,
82        _anthropic: Option<AnthropicConfig>,
83        model_behavior: Option<ModelConfig>,
84    ) -> Self {
85        let api_key_value = api_key
86            .filter(|key| !key.trim().is_empty())
87            .or_else(|| std::env::var(PRIMARY_API_KEY_ENV).ok())
88            .unwrap_or_default();
89
90        Self::with_model_internal(
91            api_key_value,
92            resolve_model(model, models::evolink::DEFAULT_MODEL),
93            base_url,
94            timeouts,
95            model_behavior,
96        )
97    }
98
99    fn with_model_internal(
100        api_key: String,
101        model: String,
102        base_url: Option<String>,
103        timeouts: Option<TimeoutsConfig>,
104        model_behavior: Option<ModelConfig>,
105    ) -> Self {
106        use crate::llm::http_client::HttpClientFactory;
107
108        let timeouts = timeouts.unwrap_or_default();
109
110        Self {
111            api_key,
112            http_client: HttpClientFactory::for_llm(&timeouts),
113            base_url: override_base_url(
114                urls::EVOLINK_API_BASE,
115                base_url,
116                Some(env_vars::EVOLINK_BASE_URL),
117            ),
118            model: Self::normalize_model(&model).to_string(),
119            model_behavior,
120        }
121    }
122
123    fn float_to_json_number(value: f32) -> Result<serde_json::Number, LLMError> {
124        serde_json::Number::from_f64(value as f64).ok_or_else(|| LLMError::InvalidRequest {
125            message: "invalid numeric parameter value (NaN or infinity)".to_string(),
126            metadata: None,
127        })
128    }
129
130    fn reasoning_effort_value(effort: ReasoningEffortLevel) -> Option<&'static str> {
131        match effort {
132            ReasoningEffortLevel::None => None,
133            ReasoningEffortLevel::Minimal | ReasoningEffortLevel::Low => Some("low"),
134            ReasoningEffortLevel::Medium => Some("medium"),
135            ReasoningEffortLevel::High
136            | ReasoningEffortLevel::XHigh
137            | ReasoningEffortLevel::Max => Some("high"),
138        }
139    }
140
141    fn is_reasoning_enabled(request: &LLMRequest) -> bool {
142        request
143            .reasoning_effort
144            .is_some_and(|effort| effort != ReasoningEffortLevel::None)
145    }
146
147    fn convert_to_evolink_format(&self, request: &LLMRequest) -> Result<Value, LLMError> {
148        let mut payload = Map::with_capacity(10);
149        payload.insert(
150            "model".to_owned(),
151            Value::String(Self::normalize_model(&request.model).to_string()),
152        );
153
154        let mut messages = serialize_messages_openai_format(request, PROVIDER_KEY)?;
155        if let Some(system_prompt) = &request.system_prompt {
156            let trimmed = system_prompt.trim();
157            if !trimmed.is_empty() {
158                messages.insert(
159                    0,
160                    serde_json::json!({ "role": "system", "content": trimmed }),
161                );
162            }
163        }
164        payload.insert("messages".to_owned(), Value::Array(messages));
165
166        if let Some(max_tokens) = request.max_tokens {
167            payload.insert(
168                "max_tokens".to_owned(),
169                Value::Number(serde_json::Number::from(max_tokens as u64)),
170            );
171        }
172
173        if !Self::is_reasoning_enabled(request) {
174            if let Some(temperature) = request.temperature {
175                payload.insert(
176                    "temperature".to_owned(),
177                    Value::Number(Self::float_to_json_number(temperature)?),
178                );
179            }
180
181            if let Some(top_p) = request.top_p {
182                payload.insert(
183                    "top_p".to_owned(),
184                    Value::Number(Self::float_to_json_number(top_p)?),
185                );
186            }
187        }
188
189        if request.stream {
190            payload.insert("stream".to_owned(), Value::Bool(true));
191        }
192
193        if let Some(tools) = &request.tools
194            && let Some(serialized_tools) = serialize_tools_openai_format(tools)
195        {
196            payload.insert("tools".to_owned(), Value::Array(serialized_tools));
197        }
198
199        if let Some(choice) = &request.tool_choice {
200            payload.insert(
201                "tool_choice".to_owned(),
202                choice.to_provider_format(PROVIDER_KEY),
203            );
204        }
205
206        if let Some(effort) = request.reasoning_effort
207            && let Some(mapped) = Self::reasoning_effort_value(effort)
208        {
209            payload.insert(
210                "reasoning_effort".to_owned(),
211                Value::String(mapped.to_string()),
212            );
213        }
214
215        Ok(Value::Object(payload))
216    }
217
218    fn is_anthropic_model(model: &str) -> bool {
219        models::evolink::is_anthropic_format(model)
220    }
221
222    fn convert_to_anthropic_format(&self, request: &LLMRequest) -> Result<Value, LLMError> {
223        let mut payload = Map::with_capacity(8);
224        let model = Self::normalize_model(&request.model).to_string();
225        payload.insert("model".to_owned(), Value::String(model));
226
227        // Anthropic uses top-level `system` field, not a system message
228        if let Some(system_prompt) = &request.system_prompt {
229            let trimmed = system_prompt.trim();
230            if !trimmed.is_empty() {
231                payload.insert("system".to_owned(), Value::String(trimmed.to_string()));
232            }
233        }
234
235        // Convert messages to Anthropic format (user/assistant only, no system)
236        let anthropic_messages: Vec<Value> = request
237            .messages
238            .iter()
239            .filter(|msg| msg.role != crate::llm::provider::MessageRole::System)
240            .map(|msg| {
241                let role = match msg.role {
242                    crate::llm::provider::MessageRole::User => "user",
243                    crate::llm::provider::MessageRole::Assistant => "assistant",
244                    _ => "user",
245                };
246                serde_json::json!({
247                    "role": role,
248                    "content": msg.content.as_text()
249                })
250            })
251            .collect();
252        payload.insert("messages".to_owned(), Value::Array(anthropic_messages));
253
254        let max_tokens = request.max_tokens.unwrap_or(8192);
255        payload.insert(
256            "max_tokens".to_owned(),
257            Value::Number(serde_json::Number::from(max_tokens as u64)),
258        );
259
260        if let Some(temperature) = request.temperature {
261            payload.insert(
262                "temperature".to_owned(),
263                Value::Number(Self::float_to_json_number(temperature)?),
264            );
265        }
266
267        if request.stream {
268            payload.insert("stream".to_owned(), Value::Bool(true));
269        }
270
271        Ok(Value::Object(payload))
272    }
273
274    fn parse_anthropic_response(
275        response_json: Value,
276        model: String,
277    ) -> Result<LLMResponse, LLMError> {
278        let content = response_json
279            .get("content")
280            .and_then(|c| c.as_array())
281            .map(|blocks| {
282                blocks
283                    .iter()
284                    .filter_map(|block| {
285                        if block.get("type").and_then(|t| t.as_str()) == Some("text") {
286                            block.get("text").and_then(|t| t.as_str()).map(String::from)
287                        } else {
288                            None
289                        }
290                    })
291                    .collect::<Vec<_>>()
292                    .join("")
293            });
294
295        let usage = response_json.get("usage").map(|u| {
296            let prompt_tokens = u.get("input_tokens").and_then(|t| t.as_u64()).unwrap_or(0) as u32;
297            let completion_tokens =
298                u.get("output_tokens").and_then(|t| t.as_u64()).unwrap_or(0) as u32;
299            crate::llm::provider::Usage {
300                prompt_tokens,
301                completion_tokens,
302                total_tokens: prompt_tokens + completion_tokens,
303                cached_prompt_tokens: u
304                    .get("cache_read_input_tokens")
305                    .and_then(|t| t.as_u64())
306                    .map(|v| v as u32),
307                cache_creation_tokens: u
308                    .get("cache_creation_input_tokens")
309                    .and_then(|t| t.as_u64())
310                    .map(|v| v as u32),
311                cache_read_tokens: None,
312                iterations: None,
313            }
314        });
315
316        let finish_reason = match response_json.get("stop_reason").and_then(|r| r.as_str()) {
317            Some("end_turn") | Some("stop_sequence") => FinishReason::Stop,
318            Some("max_tokens") => FinishReason::Length,
319            Some("tool_use") => FinishReason::ToolCalls,
320            _ => FinishReason::Stop,
321        };
322
323        Ok(LLMResponse {
324            content,
325            tool_calls: None,
326            model,
327            usage,
328            finish_reason,
329            reasoning: None,
330            reasoning_details: None,
331            tool_references: Vec::new(),
332            request_id: response_json
333                .get("id")
334                .and_then(|id| id.as_str())
335                .map(String::from),
336            organization_id: None,
337            compaction: None,
338        })
339    }
340
341    async fn generate_anthropic(
342        &self,
343        mut request: LLMRequest,
344        model: String,
345    ) -> Result<LLMResponse, LLMError> {
346        request.stream = false;
347        let payload = self.convert_to_anthropic_format(&request)?;
348        let url = format!("{}/messages", self.base_url.trim_end_matches('/'));
349
350        let response = self
351            .http_client
352            .post(&url)
353            .bearer_auth(&self.api_key)
354            .header("anthropic-version", "2023-06-01")
355            .json(&payload)
356            .send()
357            .await
358            .map_err(|error| LLMError::Network {
359                message: error_display::format_llm_error(
360                    PROVIDER_NAME,
361                    &format!("network error: {error}"),
362                ),
363                metadata: None,
364            })?;
365
366        let response =
367            handle_openai_http_error(response, PROVIDER_NAME, PRIMARY_API_KEY_ENV).await?;
368
369        let response_json: Value = response.json().await.map_err(|error| LLMError::Provider {
370            message: error_display::format_llm_error(
371                PROVIDER_NAME,
372                &format!("failed to parse Anthropic response: {error}"),
373            ),
374            metadata: None,
375        })?;
376
377        Self::parse_anthropic_response(response_json, model)
378    }
379}
380
381#[async_trait]
382impl LLMProvider for EvolinkProvider {
383    fn name(&self) -> &str {
384        PROVIDER_KEY
385    }
386
387    fn supports_streaming(&self) -> bool {
388        true
389    }
390
391    fn supports_tools(&self, _model: &str) -> bool {
392        true
393    }
394
395    fn supports_structured_output(&self, _model: &str) -> bool {
396        true
397    }
398
399    fn supports_vision(&self, _model: &str) -> bool {
400        true
401    }
402
403    fn supports_reasoning(&self, model: &str) -> bool {
404        let requested = if model.trim().is_empty() {
405            self.model.as_str()
406        } else {
407            Self::normalize_model(model)
408        };
409
410        self.model_behavior
411            .as_ref()
412            .and_then(|behavior| behavior.model_supports_reasoning)
413            .unwrap_or(false)
414            || models::evolink::REASONING_MODELS.contains(&requested)
415    }
416
417    fn supports_reasoning_effort(&self, model: &str) -> bool {
418        let requested = if model.trim().is_empty() {
419            self.model.as_str()
420        } else {
421            Self::normalize_model(model)
422        };
423
424        self.model_behavior
425            .as_ref()
426            .and_then(|behavior| behavior.model_supports_reasoning_effort)
427            .unwrap_or(false)
428            || models::evolink::REASONING_MODELS.contains(&requested)
429    }
430
431    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
432        if request.model.trim().is_empty() {
433            request.model = self.model.clone();
434        }
435        let model = Self::normalize_model(&request.model).to_string();
436
437        if Self::is_anthropic_model(&model) {
438            return self.generate_anthropic(request, model).await;
439        }
440
441        let payload = self.convert_to_evolink_format(&request)?;
442        let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
443
444        let response = self
445            .http_client
446            .post(&url)
447            .bearer_auth(&self.api_key)
448            .json(&payload)
449            .send()
450            .await
451            .map_err(|error| LLMError::Network {
452                message: error_display::format_llm_error(
453                    PROVIDER_NAME,
454                    &format!("network error: {error}"),
455                ),
456                metadata: None,
457            })?;
458
459        let response =
460            handle_openai_http_error(response, PROVIDER_NAME, PRIMARY_API_KEY_ENV).await?;
461
462        let response_json: Value = response.json().await.map_err(|error| LLMError::Provider {
463            message: error_display::format_llm_error(
464                PROVIDER_NAME,
465                &format!("failed to parse response: {error}"),
466            ),
467            metadata: None,
468        })?;
469
470        let reasoning_extractor = |message: &Value, choice: &Value| {
471            message
472                .get("reasoning")
473                .or_else(|| message.get("reasoning_content"))
474                .and_then(extract_reasoning_trace)
475                .or_else(|| choice.get("reasoning").and_then(extract_reasoning_trace))
476        };
477
478        parse_response_openai_format(
479            response_json,
480            PROVIDER_NAME,
481            model,
482            false,
483            Some(reasoning_extractor),
484        )
485    }
486
487    async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
488        if request.model.trim().is_empty() {
489            request.model = self.model.clone();
490        }
491
492        self.validate_request(&request)?;
493        let model = Self::normalize_model(&request.model).to_string();
494
495        // Anthropic models: fall back to non-streaming via generate_anthropic
496        if Self::is_anthropic_model(&model) {
497            request.stream = false;
498            let response = self.generate_anthropic(request, model).await?;
499            let (tx, rx) =
500                tokio::sync::mpsc::unbounded_channel::<Result<LLMStreamEvent, LLMError>>();
501            let _ = tx.send(Ok(LLMStreamEvent::Completed {
502                response: Box::new(response),
503            }));
504            let stream = async_stream::try_stream! {
505                let mut receiver = rx;
506                while let Some(event) = receiver.recv().await {
507                    yield event?;
508                }
509            };
510            return Ok(Box::pin(stream));
511        }
512
513        request.stream = true;
514
515        let payload = self.convert_to_evolink_format(&request)?;
516        let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
517
518        let response = self
519            .http_client
520            .post(&url)
521            .bearer_auth(&self.api_key)
522            .json(&payload)
523            .send()
524            .await
525            .map_err(|error| LLMError::Network {
526                message: error_display::format_llm_error(
527                    PROVIDER_NAME,
528                    &format!("network error: {error}"),
529                ),
530                metadata: None,
531            })?;
532
533        let response =
534            handle_openai_http_error(response, PROVIDER_NAME, PRIMARY_API_KEY_ENV).await?;
535
536        let bytes_stream = response.bytes_stream();
537        let (event_tx, event_rx) =
538            tokio::sync::mpsc::unbounded_channel::<Result<LLMStreamEvent, LLMError>>();
539        let tx = event_tx.clone();
540
541        let model_clone = model.clone();
542        tokio::spawn(async move {
543            let mut aggregator =
544                crate::llm::providers::shared::StreamAggregator::new(model_clone.clone());
545
546            let result = crate::llm::providers::shared::process_openai_stream(
547                bytes_stream,
548                PROVIDER_NAME,
549                model_clone,
550                |value| {
551                    if let Some(choices) =
552                        value.get("choices").and_then(|choices| choices.as_array())
553                        && let Some(choice) = choices.first()
554                    {
555                        if let Some(delta) = choice.get("delta") {
556                            if let Some(reasoning) = delta
557                                .get("reasoning")
558                                .or_else(|| delta.get("reasoning_content"))
559                                .and_then(|v| v.as_str())
560                                && let Some(delta) = aggregator.handle_reasoning(reasoning)
561                            {
562                                let _ = tx.send(Ok(LLMStreamEvent::Reasoning { delta }));
563                            }
564
565                            if let Some(content) = delta.get("content").and_then(|v| v.as_str()) {
566                                for event in aggregator.handle_content(content) {
567                                    let _ = tx.send(Ok(event));
568                                }
569                            }
570
571                            if let Some(tool_calls) =
572                                delta.get("tool_calls").and_then(|calls| calls.as_array())
573                            {
574                                aggregator.handle_tool_calls(tool_calls);
575                            }
576                        }
577
578                        if let Some(reason) = choice.get("finish_reason").and_then(|v| v.as_str()) {
579                            aggregator.set_finish_reason(map_finish_reason_common(reason));
580                        }
581                    }
582
583                    if let Some(_usage_value) = value.get("usage")
584                        && let Some(usage) =
585                            crate::llm::providers::common::parse_usage_openai_format(&value, false)
586                    {
587                        aggregator.set_usage(usage);
588                    }
589                    Ok(())
590                },
591            )
592            .await;
593
594            match result {
595                Ok(_) => {
596                    let response = aggregator.finalize();
597                    let _ = tx.send(Ok(LLMStreamEvent::Completed {
598                        response: Box::new(response),
599                    }));
600                }
601                Err(error) => {
602                    let _ = tx.send(Err(error));
603                }
604            }
605        });
606
607        let stream = try_stream! {
608            let mut receiver = event_rx;
609            while let Some(event) = receiver.recv().await {
610                yield event?;
611            }
612        };
613
614        Ok(Box::pin(stream))
615    }
616
617    fn supported_models(&self) -> Vec<String> {
618        models::evolink::SUPPORTED_MODELS
619            .iter()
620            .map(|model| model.to_string())
621            .collect()
622    }
623
624    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
625        // Evolink is a gateway whose upstream catalog changes over time, so do
626        // not constrain requests to the curated `SUPPORTED_MODELS` list.
627        validate_request_common(request, PROVIDER_NAME, PROVIDER_KEY, None)
628    }
629}
630
631#[async_trait]
632impl LLMClient for EvolinkProvider {
633    async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
634        let request = super::common::make_default_request(prompt, &self.model);
635        Ok(LLMProvider::generate(self, request).await?)
636    }
637
638    fn model_id(&self) -> &str {
639        &self.model
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::EvolinkProvider;
646    use crate::config::constants::{models, urls};
647    use crate::config::types::ReasoningEffortLevel;
648    use crate::llm::provider::{LLMRequest, Message};
649
650    #[test]
651    fn normalizes_namespaced_model_for_wire() {
652        let provider =
653            EvolinkProvider::with_model("test-key".to_string(), "evolink/gpt-5.2".to_string());
654        assert_eq!(provider.model_id_for_test(), models::evolink::GPT_5_2);
655    }
656
657    #[test]
658    fn defaults_to_direct_base_url() {
659        let provider = EvolinkProvider::new("test-key".to_string());
660        assert_eq!(provider.base_url_for_test(), urls::EVOLINK_API_BASE);
661    }
662
663    #[test]
664    fn payload_strips_prefix_and_maps_reasoning_effort() {
665        let provider = EvolinkProvider::new("test-key".to_string());
666        let payload = provider
667            .convert_to_evolink_format(&LLMRequest {
668                model: "evolink/deepseek-v4-pro".to_string(),
669                messages: vec![Message::user("hello".to_string())],
670                reasoning_effort: Some(ReasoningEffortLevel::High),
671                ..Default::default()
672            })
673            .expect("payload should be valid");
674
675        assert_eq!(
676            payload.get("model").and_then(|value| value.as_str()),
677            Some(models::evolink::DEEPSEEK_V4_PRO)
678        );
679        assert_eq!(
680            payload
681                .get("reasoning_effort")
682                .and_then(|value| value.as_str()),
683            Some("high")
684        );
685        assert!(payload.get("temperature").is_none());
686    }
687
688    impl EvolinkProvider {
689        fn model_id_for_test(&self) -> &str {
690            &self.model
691        }
692
693        fn base_url_for_test(&self) -> &str {
694            &self.base_url
695        }
696    }
697}