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 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 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 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 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 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}