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