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            }
313        });
314
315        let finish_reason = match response_json.get("stop_reason").and_then(|r| r.as_str()) {
316            Some("end_turn") | Some("stop_sequence") => FinishReason::Stop,
317            Some("max_tokens") => FinishReason::Length,
318            Some("tool_use") => FinishReason::ToolCalls,
319            _ => FinishReason::Stop,
320        };
321
322        Ok(LLMResponse {
323            content,
324            tool_calls: None,
325            model,
326            usage,
327            finish_reason,
328            reasoning: None,
329            reasoning_details: None,
330            tool_references: Vec::new(),
331            request_id: response_json
332                .get("id")
333                .and_then(|id| id.as_str())
334                .map(String::from),
335            organization_id: None,
336            compaction: None,
337        })
338    }
339
340    async fn generate_anthropic(
341        &self,
342        mut request: LLMRequest,
343        model: String,
344    ) -> Result<LLMResponse, LLMError> {
345        request.stream = false;
346        let payload = self.convert_to_anthropic_format(&request)?;
347        let url = format!("{}/messages", self.base_url.trim_end_matches('/'));
348
349        let response = self
350            .http_client
351            .post(&url)
352            .bearer_auth(&self.api_key)
353            .header("anthropic-version", "2023-06-01")
354            .json(&payload)
355            .send()
356            .await
357            .map_err(|error| LLMError::Network {
358                message: error_display::format_llm_error(
359                    PROVIDER_NAME,
360                    &format!("network error: {error}"),
361                ),
362                metadata: None,
363            })?;
364
365        let response =
366            handle_openai_http_error(response, PROVIDER_NAME, PRIMARY_API_KEY_ENV).await?;
367
368        let response_json: Value = response.json().await.map_err(|error| LLMError::Provider {
369            message: error_display::format_llm_error(
370                PROVIDER_NAME,
371                &format!("failed to parse Anthropic response: {error}"),
372            ),
373            metadata: None,
374        })?;
375
376        Self::parse_anthropic_response(response_json, model)
377    }
378}
379
380#[async_trait]
381impl LLMProvider for EvolinkProvider {
382    fn name(&self) -> &str {
383        PROVIDER_KEY
384    }
385
386    fn supports_streaming(&self) -> bool {
387        true
388    }
389
390    fn supports_tools(&self, _model: &str) -> bool {
391        true
392    }
393
394    fn supports_structured_output(&self, _model: &str) -> bool {
395        true
396    }
397
398    fn supports_vision(&self, _model: &str) -> bool {
399        true
400    }
401
402    fn supports_reasoning(&self, model: &str) -> bool {
403        let requested = if model.trim().is_empty() {
404            self.model.as_str()
405        } else {
406            Self::normalize_model(model)
407        };
408
409        self.model_behavior
410            .as_ref()
411            .and_then(|behavior| behavior.model_supports_reasoning)
412            .unwrap_or(false)
413            || models::evolink::REASONING_MODELS.contains(&requested)
414    }
415
416    fn supports_reasoning_effort(&self, model: &str) -> bool {
417        let requested = if model.trim().is_empty() {
418            self.model.as_str()
419        } else {
420            Self::normalize_model(model)
421        };
422
423        self.model_behavior
424            .as_ref()
425            .and_then(|behavior| behavior.model_supports_reasoning_effort)
426            .unwrap_or(false)
427            || models::evolink::REASONING_MODELS.contains(&requested)
428    }
429
430    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
431        if request.model.trim().is_empty() {
432            request.model = self.model.clone();
433        }
434        let model = Self::normalize_model(&request.model).to_string();
435
436        if Self::is_anthropic_model(&model) {
437            return self.generate_anthropic(request, model).await;
438        }
439
440        let payload = self.convert_to_evolink_format(&request)?;
441        let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
442
443        let response = self
444            .http_client
445            .post(&url)
446            .bearer_auth(&self.api_key)
447            .json(&payload)
448            .send()
449            .await
450            .map_err(|error| LLMError::Network {
451                message: error_display::format_llm_error(
452                    PROVIDER_NAME,
453                    &format!("network error: {error}"),
454                ),
455                metadata: None,
456            })?;
457
458        let response =
459            handle_openai_http_error(response, PROVIDER_NAME, PRIMARY_API_KEY_ENV).await?;
460
461        let response_json: Value = response.json().await.map_err(|error| LLMError::Provider {
462            message: error_display::format_llm_error(
463                PROVIDER_NAME,
464                &format!("failed to parse response: {error}"),
465            ),
466            metadata: None,
467        })?;
468
469        let reasoning_extractor = |message: &Value, choice: &Value| {
470            message
471                .get("reasoning")
472                .or_else(|| message.get("reasoning_content"))
473                .and_then(extract_reasoning_trace)
474                .or_else(|| choice.get("reasoning").and_then(extract_reasoning_trace))
475        };
476
477        parse_response_openai_format(
478            response_json,
479            PROVIDER_NAME,
480            model,
481            false,
482            Some(reasoning_extractor),
483        )
484    }
485
486    async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
487        if request.model.trim().is_empty() {
488            request.model = self.model.clone();
489        }
490
491        self.validate_request(&request)?;
492        let model = Self::normalize_model(&request.model).to_string();
493
494        // Anthropic models: fall back to non-streaming via generate_anthropic
495        if Self::is_anthropic_model(&model) {
496            request.stream = false;
497            let response = self.generate_anthropic(request, model).await?;
498            let (tx, rx) =
499                tokio::sync::mpsc::unbounded_channel::<Result<LLMStreamEvent, LLMError>>();
500            let _ = tx.send(Ok(LLMStreamEvent::Completed {
501                response: Box::new(response),
502            }));
503            let stream = async_stream::try_stream! {
504                let mut receiver = rx;
505                while let Some(event) = receiver.recv().await {
506                    yield event?;
507                }
508            };
509            return Ok(Box::pin(stream));
510        }
511
512        request.stream = true;
513
514        let payload = self.convert_to_evolink_format(&request)?;
515        let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
516
517        let response = self
518            .http_client
519            .post(&url)
520            .bearer_auth(&self.api_key)
521            .json(&payload)
522            .send()
523            .await
524            .map_err(|error| LLMError::Network {
525                message: error_display::format_llm_error(
526                    PROVIDER_NAME,
527                    &format!("network error: {error}"),
528                ),
529                metadata: None,
530            })?;
531
532        let response =
533            handle_openai_http_error(response, PROVIDER_NAME, PRIMARY_API_KEY_ENV).await?;
534
535        let bytes_stream = response.bytes_stream();
536        let (event_tx, event_rx) =
537            tokio::sync::mpsc::unbounded_channel::<Result<LLMStreamEvent, LLMError>>();
538        let tx = event_tx.clone();
539
540        let model_clone = model.clone();
541        tokio::spawn(async move {
542            let mut aggregator =
543                crate::llm::providers::shared::StreamAggregator::new(model_clone.clone());
544
545            let result = crate::llm::providers::shared::process_openai_stream(
546                bytes_stream,
547                PROVIDER_NAME,
548                model_clone,
549                |value| {
550                    if let Some(choices) =
551                        value.get("choices").and_then(|choices| choices.as_array())
552                        && let Some(choice) = choices.first()
553                    {
554                        if let Some(delta) = choice.get("delta") {
555                            if let Some(reasoning) = delta
556                                .get("reasoning")
557                                .or_else(|| delta.get("reasoning_content"))
558                                .and_then(|v| v.as_str())
559                                && let Some(delta) = aggregator.handle_reasoning(reasoning)
560                            {
561                                let _ = tx.send(Ok(LLMStreamEvent::Reasoning { delta }));
562                            }
563
564                            if let Some(content) = delta.get("content").and_then(|v| v.as_str()) {
565                                for event in aggregator.handle_content(content) {
566                                    let _ = tx.send(Ok(event));
567                                }
568                            }
569
570                            if let Some(tool_calls) =
571                                delta.get("tool_calls").and_then(|calls| calls.as_array())
572                            {
573                                aggregator.handle_tool_calls(tool_calls);
574                            }
575                        }
576
577                        if let Some(reason) = choice.get("finish_reason").and_then(|v| v.as_str()) {
578                            aggregator.set_finish_reason(map_finish_reason_common(reason));
579                        }
580                    }
581
582                    if let Some(_usage_value) = value.get("usage")
583                        && let Some(usage) =
584                            crate::llm::providers::common::parse_usage_openai_format(&value, false)
585                    {
586                        aggregator.set_usage(usage);
587                    }
588                    Ok(())
589                },
590            )
591            .await;
592
593            match result {
594                Ok(_) => {
595                    let response = aggregator.finalize();
596                    let _ = tx.send(Ok(LLMStreamEvent::Completed {
597                        response: Box::new(response),
598                    }));
599                }
600                Err(error) => {
601                    let _ = tx.send(Err(error));
602                }
603            }
604        });
605
606        let stream = try_stream! {
607            let mut receiver = event_rx;
608            while let Some(event) = receiver.recv().await {
609                yield event?;
610            }
611        };
612
613        Ok(Box::pin(stream))
614    }
615
616    fn supported_models(&self) -> Vec<String> {
617        models::evolink::SUPPORTED_MODELS
618            .iter()
619            .map(|model| model.to_string())
620            .collect()
621    }
622
623    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
624        // Evolink is a gateway whose upstream catalog changes over time, so do
625        // not constrain requests to the curated `SUPPORTED_MODELS` list.
626        validate_request_common(request, PROVIDER_NAME, PROVIDER_KEY, None)
627    }
628}
629
630#[async_trait]
631impl LLMClient for EvolinkProvider {
632    async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
633        let request = super::common::make_default_request(prompt, &self.model);
634        Ok(LLMProvider::generate(self, request).await?)
635    }
636
637    fn model_id(&self) -> &str {
638        &self.model
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::EvolinkProvider;
645    use crate::config::constants::{models, urls};
646    use crate::config::types::ReasoningEffortLevel;
647    use crate::llm::provider::{LLMRequest, Message};
648
649    #[test]
650    fn normalizes_namespaced_model_for_wire() {
651        let provider =
652            EvolinkProvider::with_model("test-key".to_string(), "evolink/gpt-5.2".to_string());
653        assert_eq!(provider.model_id_for_test(), models::evolink::GPT_5_2);
654    }
655
656    #[test]
657    fn defaults_to_direct_base_url() {
658        let provider = EvolinkProvider::new("test-key".to_string());
659        assert_eq!(provider.base_url_for_test(), urls::EVOLINK_API_BASE);
660    }
661
662    #[test]
663    fn payload_strips_prefix_and_maps_reasoning_effort() {
664        let provider = EvolinkProvider::new("test-key".to_string());
665        let payload = provider
666            .convert_to_evolink_format(&LLMRequest {
667                model: "evolink/deepseek-v4-pro".to_string(),
668                messages: vec![Message::user("hello".to_string())],
669                reasoning_effort: Some(ReasoningEffortLevel::High),
670                ..Default::default()
671            })
672            .expect("payload should be valid");
673
674        assert_eq!(
675            payload.get("model").and_then(|value| value.as_str()),
676            Some(models::evolink::DEEPSEEK_V4_PRO)
677        );
678        assert_eq!(
679            payload
680                .get("reasoning_effort")
681                .and_then(|value| value.as_str()),
682            Some("high")
683        );
684        assert!(payload.get("temperature").is_none());
685    }
686
687    impl EvolinkProvider {
688        fn model_id_for_test(&self) -> &str {
689            &self.model
690        }
691
692        fn base_url_for_test(&self) -> &str {
693            &self.base_url
694        }
695    }
696}