1use serde_json::{Map, Value};
2
3use crate::client::LLMClient;
4use crate::error_display;
5use crate::provider::{FinishReason, LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent};
6use vtcode_config::constants::{env_vars, models, urls};
7use vtcode_config::core::PromptCachingConfig;
8use vtcode_config::types::ReasoningEffortLevel;
9
10use super::common::{collect_history_system_directives, merge_system_prompt_with_history_directives};
11use super::error_handling::{format_network_error, handle_openai_http_error};
12use super::extract_reasoning_trace;
13use super::openai_compat::{OpenAiCompatCore, OpenAiCompatSpec};
14
15const PROVIDER_NAME: &str = "Evolink";
16const PROVIDER_KEY: &str = "evolink";
17const PRIMARY_API_KEY_ENV: &str = "EVOLINK_API_KEY";
18
19pub struct EvolinkSpec;
20
21fn normalize(model: &str) -> &str {
25 model.trim().strip_prefix("evolink/").unwrap_or(model.trim())
26}
27
28fn evolink_reasoning(message: &Value, choice: &Value) -> Option<String> {
29 message
30 .get("reasoning")
31 .and_then(extract_reasoning_trace)
32 .or_else(|| message.get("reasoning_content").and_then(extract_reasoning_trace))
33 .or_else(|| choice.get("reasoning").and_then(extract_reasoning_trace))
34}
35
36fn reasoning_effort_value(effort: ReasoningEffortLevel) -> Option<&'static str> {
37 match effort {
38 ReasoningEffortLevel::None | ReasoningEffortLevel::Unknown => None,
39 ReasoningEffortLevel::Minimal | ReasoningEffortLevel::Low => Some("low"),
40 ReasoningEffortLevel::Medium => Some("medium"),
41 ReasoningEffortLevel::High | ReasoningEffortLevel::XHigh | ReasoningEffortLevel::Max => Some("high"),
42 }
43}
44
45impl OpenAiCompatSpec for EvolinkSpec {
46 const NAME: &'static str = PROVIDER_NAME;
47 const KEY: &'static str = PROVIDER_KEY;
48 const API_KEY_ENV: &'static str = PRIMARY_API_KEY_ENV;
49 const DEFAULT_MODEL: &'static str = models::evolink::DEFAULT_MODEL;
50 const DEFAULT_BASE_URL: &'static str = urls::EVOLINK_API_BASE;
51 const BASE_URL_ENV: Option<&'static str> = Some(env_vars::EVOLINK_BASE_URL);
52 const LISTED_MODELS: &'static [&'static str] = models::evolink::SUPPORTED_MODELS;
53 const VALIDATION_ALLOWLIST: Option<&'static [&'static str]> = None;
56
57 const STREAM_REASONING_FIELDS: &'static [&'static str] = &["reasoning", "reasoning_content"];
58 const RESPONSE_REASONING_EXTRACTOR: Option<super::openai_compat::ReasoningExtractor> = Some(evolink_reasoning);
59
60 fn resolve_api_key(api_key: Option<String>) -> String {
61 api_key
62 .or_else(|| std::env::var(Self::API_KEY_ENV).ok().filter(|key| !key.trim().is_empty()))
63 .unwrap_or_default()
64 }
65
66 fn normalize_model(model: String) -> String {
70 normalize(&model).to_string()
71 }
72
73 fn prompt_cache_enabled(_prompt_cache: Option<&PromptCachingConfig>) -> bool {
74 false
75 }
76
77 fn insert_reasoning(
78 _core: &OpenAiCompatCore<Self>,
79 request: &LLMRequest,
80 payload: &mut Map<String, Value>,
81 ) -> Result<(), LLMError> {
82 if let Some(effort) = request.reasoning_effort
83 && let Some(mapped) = reasoning_effort_value(effort)
84 {
85 payload.insert("reasoning_effort".to_owned(), Value::String(mapped.to_string()));
86 }
87 Ok(())
88 }
89}
90
91pub struct EvolinkProvider {
92 core: OpenAiCompatCore<EvolinkSpec>,
93}
94
95impl EvolinkProvider {
96 fn new(api_key: String) -> Self {
97 Self::with_model(api_key, models::evolink::DEFAULT_MODEL.to_string())
98 }
99
100 fn with_model(api_key: String, model: String) -> Self {
101 Self { core: OpenAiCompatCore::direct(api_key, model) }
102 }
103
104 pub fn new_with_client(
105 api_key: String,
106 model: String,
107 http_client: reqwest::Client,
108 base_url: String,
109 _timeouts: vtcode_config::TimeoutsConfig,
110 ) -> Self {
111 Self {
112 core: OpenAiCompatCore::from_parts(api_key, model, http_client, base_url),
113 }
114 }
115
116 pub fn from_config(
117 api_key: Option<String>,
118 model: Option<String>,
119 base_url: Option<String>,
120 _prompt_cache: Option<PromptCachingConfig>,
121 timeouts: Option<vtcode_config::TimeoutsConfig>,
122 _anthropic: Option<vtcode_config::core::AnthropicConfig>,
123 model_behavior: Option<vtcode_config::core::ModelConfig>,
124 ) -> Self {
125 Self {
126 core: OpenAiCompatCore::from_config(api_key, model, base_url, _prompt_cache, timeouts, model_behavior),
127 }
128 }
129
130 fn is_anthropic_model(model: &str) -> bool {
131 models::evolink::is_anthropic_format(model)
132 }
133
134 fn convert_to_anthropic_format(&self, request: &LLMRequest) -> Result<Value, LLMError> {
135 let mut payload = Map::with_capacity(8);
136 let model = normalize(&request.model).to_string();
137 payload.insert("model".to_owned(), Value::String(model));
138
139 let history_system_directives = collect_history_system_directives(request);
142 let system_prompt = merge_system_prompt_with_history_directives(
143 request.system_prompt.as_deref(),
144 &history_system_directives,
145 "[History Directives]",
146 );
147 if let Some(system_prompt) = system_prompt {
148 let trimmed = system_prompt.trim();
149 if !trimmed.is_empty() {
150 payload.insert("system".to_owned(), Value::String(trimmed.to_string()));
151 }
152 }
153
154 let anthropic_messages: Vec<Value> = request
156 .messages
157 .iter()
158 .filter(|msg| msg.role != crate::provider::MessageRole::System)
159 .map(|msg| {
160 let role = match msg.role {
161 crate::provider::MessageRole::User => "user",
162 crate::provider::MessageRole::Assistant => "assistant",
163 _ => "user",
164 };
165 serde_json::json!({
166 "role": role,
167 "content": msg.content.as_text()
168 })
169 })
170 .collect();
171 payload.insert("messages".to_owned(), Value::Array(anthropic_messages));
172
173 let max_tokens = request.max_tokens.unwrap_or(8192);
174 payload.insert("max_tokens".to_owned(), Value::Number(serde_json::Number::from(max_tokens as u64)));
175
176 if let Some(temperature) = request.temperature {
177 payload.insert("temperature".to_owned(), Value::Number(super::common::float_to_json_number(temperature)?));
178 }
179
180 if request.stream {
181 payload.insert("stream".to_owned(), Value::Bool(true));
182 }
183
184 Ok(Value::Object(payload))
185 }
186
187 fn parse_anthropic_response(response_json: Value, model: String) -> Result<LLMResponse, LLMError> {
188 let content = response_json.get("content").and_then(|c| c.as_array()).map(|blocks| {
189 blocks
190 .iter()
191 .filter_map(|block| {
192 if block.get("type").and_then(|t| t.as_str()) == Some("text") {
193 block.get("text").and_then(|t| t.as_str()).map(String::from)
194 } else {
195 None
196 }
197 })
198 .collect::<Vec<_>>()
199 .join("")
200 });
201
202 let usage = response_json.get("usage").map(|u| {
203 let prompt_tokens = u.get("input_tokens").and_then(|t| t.as_u64()).unwrap_or(0) as u32;
204 let completion_tokens = u.get("output_tokens").and_then(|t| t.as_u64()).unwrap_or(0) as u32;
205 crate::provider::Usage {
206 prompt_tokens,
207 completion_tokens,
208 total_tokens: prompt_tokens + completion_tokens,
209 cached_prompt_tokens: u.get("cache_read_input_tokens").and_then(|t| t.as_u64()).map(|v| v as u32),
210 cache_creation_tokens: u.get("cache_creation_input_tokens").and_then(|t| t.as_u64()).map(|v| v as u32),
211 cache_read_tokens: None,
212 iterations: None,
213 }
214 });
215
216 let finish_reason = match response_json.get("stop_reason").and_then(|r| r.as_str()) {
217 Some("end_turn") | Some("stop_sequence") => FinishReason::Stop,
218 Some("max_tokens") => FinishReason::Length,
219 Some("tool_use") => FinishReason::ToolCalls,
220 _ => FinishReason::Stop,
221 };
222
223 Ok(LLMResponse {
224 content,
225 tool_calls: None,
226 model,
227 usage,
228 finish_reason,
229 reasoning: None,
230 reasoning_details: None,
231 tool_references: Vec::new(),
232 request_id: response_json.get("id").and_then(|id| id.as_str()).map(String::from),
233 organization_id: None,
234 compaction: None,
235 })
236 }
237
238 async fn generate_anthropic(&self, mut request: LLMRequest, model: String) -> Result<LLMResponse, LLMError> {
239 request.stream = false;
240 let payload = self.convert_to_anthropic_format(&request)?;
241 let url = format!("{}/messages", self.core.base_url.trim_end_matches('/'));
242
243 let response = self
244 .core
245 .http_client
246 .post(&url)
247 .bearer_auth(&self.core.api_key)
248 .header("anthropic-version", "2023-06-01")
249 .json(&payload)
250 .send()
251 .await
252 .map_err(|error| format_network_error(PROVIDER_NAME, &error))?;
253
254 let response = handle_openai_http_error(response, PROVIDER_NAME, PRIMARY_API_KEY_ENV).await?;
255
256 let response_json: Value = response.json().await.map_err(|error| LLMError::Provider {
257 message: error_display::format_llm_error(
258 PROVIDER_NAME,
259 &format!("failed to parse Anthropic response: {error}"),
260 ),
261 metadata: None,
262 })?;
263
264 Self::parse_anthropic_response(response_json, model)
265 }
266}
267
268#[async_trait::async_trait]
269impl LLMProvider for EvolinkProvider {
270 fn name(&self) -> &str {
271 EvolinkSpec::KEY
272 }
273
274 fn supports_streaming(&self) -> bool {
275 true
276 }
277
278 fn supports_non_streaming(&self, _model: &str) -> bool {
279 true
281 }
282
283 fn supports_tools(&self, _model: &str) -> bool {
284 true
285 }
286
287 fn supports_structured_output(&self, _model: &str) -> bool {
288 true
289 }
290
291 fn supports_vision(&self, _model: &str) -> bool {
292 true
293 }
294
295 fn supports_reasoning(&self, model: &str) -> bool {
296 let requested = if model.trim().is_empty() {
297 self.core.model.as_str()
298 } else {
299 normalize(model)
300 };
301
302 self.core
303 .model_behavior
304 .as_ref()
305 .and_then(|behavior| behavior.model_supports_reasoning)
306 .unwrap_or(false)
307 || models::evolink::REASONING_MODELS.contains(&requested)
308 }
309
310 fn supports_reasoning_effort(&self, model: &str) -> bool {
311 let requested = if model.trim().is_empty() {
312 self.core.model.as_str()
313 } else {
314 normalize(model)
315 };
316
317 self.core
318 .model_behavior
319 .as_ref()
320 .and_then(|behavior| behavior.model_supports_reasoning_effort)
321 .unwrap_or(false)
322 || models::evolink::REASONING_MODELS.contains(&requested)
323 }
324
325 async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
326 self.core.prepare(&mut request);
327 let model = request.model.clone();
328
329 if Self::is_anthropic_model(&model) {
330 return self.generate_anthropic(request, model).await;
331 }
332
333 self.core.generate_prepared(request).await
334 }
335
336 async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
337 self.core.prepare(&mut request);
338 self.validate_request(&request)?;
339 let model = request.model.clone();
340
341 if Self::is_anthropic_model(&model) {
343 request.stream = false;
344 let response = self.generate_anthropic(request, model).await?;
345 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Result<LLMStreamEvent, LLMError>>();
346 let _ = tx.send(Ok(LLMStreamEvent::Completed { response: Box::new(response) }));
347 let stream = async_stream::try_stream! {
348 let mut receiver = rx;
349 while let Some(event) = receiver.recv().await {
350 yield event?;
351 }
352 };
353 return Ok(Box::pin(stream));
354 }
355
356 request.stream = true;
357 self.core.stream_prepared(request).await
358 }
359
360 fn supported_models(&self) -> Vec<String> {
361 self.core.supported_models()
362 }
363
364 fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
365 self.core.validate(request)
369 }
370}
371
372#[async_trait::async_trait]
373impl LLMClient for EvolinkProvider {
374 async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
375 let request = super::common::make_default_request(prompt, &self.core.model);
376 Ok(LLMProvider::generate(self, request).await?)
377 }
378
379 fn model_id(&self) -> &str {
380 &self.core.model
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::EvolinkProvider;
387 use crate::provider::{LLMRequest, Message, ToolChoice};
388 use std::sync::Arc;
389 use vtcode_config::constants::{models, urls};
390 use vtcode_config::types::ReasoningEffortLevel;
391
392 #[test]
393 fn normalizes_namespaced_model_for_wire() {
394 let provider = EvolinkProvider::with_model("test-key".to_string(), "evolink/gpt-5.6".to_string());
395 assert_eq!(provider.model_id_for_test(), models::evolink::GPT_5_6);
396 }
397
398 #[test]
399 fn defaults_to_direct_base_url() {
400 let provider = EvolinkProvider::new("test-key".to_string());
401 assert_eq!(provider.base_url_for_test(), urls::EVOLINK_API_BASE);
402 }
403
404 #[test]
405 fn payload_strips_prefix_and_maps_reasoning_effort() {
406 let provider = EvolinkProvider::new("test-key".to_string());
407 let mut request = LLMRequest {
408 model: "evolink/gpt-5.6".to_string(),
409 messages: vec![Message::user("hello".to_string())].into(),
410 reasoning_effort: Some(ReasoningEffortLevel::High),
411 ..Default::default()
412 };
413 provider.core.prepare(&mut request);
414 let payload = provider.core.convert_request(&request).expect("payload should be valid");
415
416 assert_eq!(payload.get("model").and_then(|value| value.as_str()), Some(models::evolink::GPT_5_6));
417 assert_eq!(payload.get("reasoning_effort").and_then(|value| value.as_str()), Some("high"));
418 assert!(payload.get("temperature").is_none());
419 }
420
421 #[test]
422 fn golden_payload_basic_shape() {
423 let provider = EvolinkProvider::new("test-key".to_string());
424 let mut request = LLMRequest {
425 model: "evolink/gpt-5.6".to_string(),
426 messages: vec![Message::user("hello".to_string())].into(),
427 system_prompt: Some(Arc::from("system guidance")),
428 max_tokens: Some(512),
429 temperature: Some(0.5),
430 top_p: Some(0.25),
431 stream: true,
432 tool_choice: Some(ToolChoice::Auto),
433 metadata: Some(serde_json::json!({"user_id": "user-42"})),
434 ..Default::default()
435 };
436 provider.core.prepare(&mut request);
437 let payload = provider.core.convert_request(&request).expect("payload should be valid");
438
439 assert_eq!(payload.get("model").and_then(|value| value.as_str()), Some(models::evolink::GPT_5_6));
440 let messages = payload.get("messages").and_then(|v| v.as_array()).unwrap();
441 assert_eq!(messages.len(), 2);
442 assert_eq!(messages[0]["role"], "system");
443 assert_eq!(messages[0]["content"], "system guidance");
444 assert_eq!(payload["max_tokens"], 512);
445 assert_eq!(payload["temperature"], 0.5);
446 assert_eq!(payload["top_p"], 0.25);
447 assert_eq!(payload["stream"], true);
448 assert!(payload.get("stream_options").is_none());
449 assert!(payload.get("user_id").is_none());
450 assert_eq!(payload["tool_choice"], "auto");
451 assert!(payload.get("reasoning_effort").is_none());
452 }
453
454 #[test]
455 fn golden_anthropic_payload_shape() {
456 let provider = EvolinkProvider::new("test-key".to_string());
457 let payload = provider
458 .convert_to_anthropic_format(&LLMRequest {
459 model: "evolink/claude-x".to_string(),
460 messages: vec![Message::user("hello".to_string())].into(),
461 system_prompt: Some(Arc::from("system guidance")),
462 temperature: Some(0.5),
463 stream: false,
464 ..Default::default()
465 })
466 .expect("payload should be valid");
467
468 assert_eq!(payload["system"], "system guidance");
469 let messages = payload.get("messages").and_then(|v| v.as_array()).unwrap();
470 assert_eq!(messages.len(), 1);
471 assert_eq!(messages[0]["role"], "user");
472 assert_eq!(payload["max_tokens"], 8192);
473 assert_eq!(payload["temperature"], 0.5);
474 assert!(payload.get("stream").is_none());
475 }
476
477 #[test]
478 fn anthropic_payload_promotes_history_system_directives() {
479 let provider = EvolinkProvider::new("test-key".to_string());
480 let payload = provider
481 .convert_to_anthropic_format(&LLMRequest {
482 model: "evolink/claude-x".to_string(),
483 messages: vec![
484 Message::user("run a command".to_string()),
485 Message::system("Only you see that command's output".to_string()),
486 Message::user("continue".to_string()),
487 ]
488 .into(),
489 system_prompt: Some(Arc::from("system guidance")),
490 ..Default::default()
491 })
492 .expect("payload should promote history directives");
493
494 assert!(payload["system"].as_str().is_some_and(|system| {
495 system.contains("system guidance") && system.contains("Only you see that command's output")
496 }));
497 let messages = payload.get("messages").and_then(|v| v.as_array()).unwrap();
498 assert_eq!(messages.len(), 2);
499 assert!(messages.iter().all(|message| message["role"] != "system"));
500 }
501
502 impl EvolinkProvider {
503 fn model_id_for_test(&self) -> &str {
504 &self.core.model
505 }
506
507 fn base_url_for_test(&self) -> &str {
508 &self.core.base_url
509 }
510 }
511}