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