1use crate::error::ApiError;
2use crate::sse::SseParser;
3use crate::types::*;
4use std::collections::VecDeque;
5use std::time::Duration;
6
7const DEFAULT_BASE_URL: &str = "https://api.ternlang.com";
8const REQUEST_ID_HEADER: &str = "x-request-id";
9const ALT_REQUEST_ID_HEADER: &str = "request-id";
10const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(500);
11const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(30);
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
14pub enum LlmProvider {
15 Ternlang,
17 Anthropic,
18 OpenAi,
19 Google,
20 Xai,
21 Groq,
23 Mistral,
24 DeepSeek,
25 Together,
26 Fireworks,
27 DeepInfra,
28 OpenRouter,
29 Perplexity,
30 Cohere,
31 Cerebras,
32 Novita,
33 SambaNova,
34 NvidiaNim,
35 Zhipu,
37 MiniMax,
38 Qwen,
39 Azure,
41 Aws,
42 HuggingFace,
44 GitHub,
45 Ollama,
47 LmStudio,
48 OpenAiCompat,
50}
51
52impl LlmProvider {
53 pub fn is_openai_compat(self) -> bool {
55 !matches!(self, Self::Anthropic | Self::Google | Self::Ternlang | Self::Aws)
56 }
57
58 pub fn default_base_url(&self) -> &'static str {
59 match self {
60 Self::Ternlang => "https://api.ternlang.com",
61 Self::Anthropic => "https://api.anthropic.com",
62 Self::OpenAi => "https://api.openai.com",
63 Self::Google => "https://generativelanguage.googleapis.com",
64 Self::Xai => "https://api.x.ai",
65 Self::Groq => "https://api.groq.com/openai",
66 Self::Mistral => "https://api.mistral.ai",
67 Self::DeepSeek => "https://api.deepseek.com",
68 Self::Together => "https://api.together.xyz",
69 Self::Fireworks => "https://api.fireworks.ai/inference",
70 Self::DeepInfra => "https://api.deepinfra.com/v1/openai",
71 Self::OpenRouter => "https://openrouter.ai/api",
72 Self::Perplexity => "https://api.perplexity.ai",
73 Self::Cohere => "https://api.cohere.ai",
74 Self::Cerebras => "https://api.cerebras.ai",
75 Self::Novita => "https://api.novita.ai/v3/openai",
76 Self::SambaNova => "https://api.sambanova.ai",
77 Self::NvidiaNim => "https://integrate.api.nvidia.com",
78 Self::Zhipu => "https://open.bigmodel.cn/api/paas/v4",
79 Self::MiniMax => "https://api.minimax.chat/v1",
80 Self::Qwen => "https://dashscope.aliyuncs.com/compatible-mode/v1",
81 Self::Azure => "https://api.azure.com",
82 Self::Aws => "https://bedrock-runtime.us-east-1.amazonaws.com",
83 Self::HuggingFace => "https://api-inference.huggingface.co",
84 Self::GitHub => "https://models.inference.ai.azure.com",
85 Self::Ollama => "http://localhost:11434",
86 Self::LmStudio => "http://localhost:1234",
87 Self::OpenAiCompat => "http://localhost:11434",
88 }
89 }
90
91 pub fn api_path(&self) -> &'static str {
92 match self {
93 Self::Ternlang | Self::Anthropic => "/v1/messages",
94 Self::Google => "/v1beta",
95 Self::HuggingFace => "/models",
96 _ => "/v1/chat/completions",
98 }
99 }
100
101 pub fn env_var(self) -> &'static str {
103 match self {
104 Self::Ternlang => "TERNLANG_API_KEY",
105 Self::Anthropic => "ANTHROPIC_API_KEY",
106 Self::OpenAi => "OPENAI_API_KEY",
107 Self::Google => "GEMINI_API_KEY",
108 Self::Xai => "XAI_API_KEY",
109 Self::Groq => "GROQ_API_KEY",
110 Self::Mistral => "MISTRAL_API_KEY",
111 Self::DeepSeek => "DEEPSEEK_API_KEY",
112 Self::Together => "TOGETHER_API_KEY",
113 Self::Fireworks => "FIREWORKS_API_KEY",
114 Self::DeepInfra => "DEEPINFRA_API_KEY",
115 Self::OpenRouter => "OPENROUTER_API_KEY",
116 Self::Perplexity => "PERPLEXITY_API_KEY",
117 Self::Cohere => "COHERE_API_KEY",
118 Self::Cerebras => "CEREBRAS_API_KEY",
119 Self::Novita => "NOVITA_API_KEY",
120 Self::SambaNova => "SAMBANOVA_API_KEY",
121 Self::NvidiaNim => "NVIDIA_API_KEY",
122 Self::Zhipu => "ZHIPU_API_KEY",
123 Self::MiniMax => "MINIMAX_API_KEY",
124 Self::Qwen => "DASHSCOPE_API_KEY",
125 Self::Azure => "AZURE_OPENAI_API_KEY",
126 Self::Aws => "AWS_ACCESS_KEY_ID",
127 Self::HuggingFace => "HUGGINGFACE_API_KEY",
128 Self::GitHub => "GITHUB_TOKEN",
129 Self::Ollama => "",
130 Self::LmStudio => "",
131 Self::OpenAiCompat => "OPENAI_API_KEY",
132 }
133 }
134}
135
136#[derive(Clone)]
137pub struct TernlangClient {
138 pub provider: LlmProvider,
139 pub base_url: String,
140 pub auth: AuthSource,
141 pub http: reqwest::Client,
142 pub max_retries: u32,
143 pub initial_backoff: Duration,
144 pub max_backoff: Duration,
145}
146
147impl TernlangClient {
148 pub fn from_auth(auth: AuthSource) -> Self {
149 Self {
150 provider: LlmProvider::Ternlang,
151 base_url: DEFAULT_BASE_URL.to_string(),
152 auth,
153 http: reqwest::Client::new(),
154 max_retries: 3,
155 initial_backoff: DEFAULT_INITIAL_BACKOFF,
156 max_backoff: DEFAULT_MAX_BACKOFF,
157 }
158 }
159
160 pub fn from_env() -> Result<Self, ApiError> {
161 Ok(Self::from_auth(AuthSource::from_env_or_saved()?).with_base_url(read_base_url()))
162 }
163
164 #[must_use]
165 pub fn with_auth_source(mut self, auth: AuthSource) -> Self {
166 self.auth = auth;
167 self
168 }
169
170 #[must_use]
171 pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
172 self.base_url = base_url.into();
173 self
174 }
175
176 #[must_use]
177 pub fn with_provider(mut self, provider: LlmProvider) -> Self {
178 self.provider = provider;
179 if self.base_url == DEFAULT_BASE_URL {
180 self.base_url = provider.default_base_url().to_string();
181 }
182 self
183 }
184
185 async fn send_raw_request(
186 &self,
187 request: &MessageRequest,
188 ) -> Result<reqwest::Response, ApiError> {
189 let path = self.provider.api_path();
190 let mut request_url = format!("{}/{}", self.base_url.trim_end_matches('/'), path.trim_start_matches('/'));
191
192 let body = match self.provider {
193 LlmProvider::Google => {
194 let model_id = if request.model.starts_with("models/") {
195 request.model.clone()
196 } else {
197 format!("models/{}", request.model)
198 };
199 let base = format!("{}/v1beta/{}:generateContent", self.base_url.trim_end_matches('/'), model_id);
200 request_url = if let Some(key) = self.auth.api_key() {
201 format!("{}?key={}", base, key)
202 } else {
203 base
204 };
205 translate_to_gemini(request)
206 }
207 LlmProvider::Anthropic => translate_to_anthropic(request),
208 LlmProvider::Ternlang | LlmProvider::Aws => {
209 serde_json::to_value(request).map_err(ApiError::from)?
210 }
211 _ if self.provider.is_openai_compat() => translate_to_openai(request),
212 _ => serde_json::to_value(request).map_err(ApiError::from)?,
213 };
214
215 let mut request_builder = self
216 .http
217 .post(&request_url)
218 .header("content-type", "application/json");
219
220 if self.provider == LlmProvider::Anthropic {
221 request_builder = request_builder.header("anthropic-version", "2023-06-01");
222 }
223
224 let request_builder = self.auth.apply(self.provider, request_builder);
225
226 request_builder.json(&body).send().await.map_err(ApiError::from)
227 }
228
229 pub async fn send_message(
230 &self,
231 request: &MessageRequest,
232 ) -> Result<MessageResponse, ApiError> {
233 let request = MessageRequest {
234 stream: false,
235 ..request.clone()
236 };
237 let response = self.send_with_retry(&request).await?;
238 let request_id = request_id_from_headers(response.headers());
239 let response_json = response
240 .json::<serde_json::Value>()
241 .await
242 .map_err(ApiError::from)?;
243
244 let mut final_response = match self.provider {
245 LlmProvider::Google => translate_from_gemini(response_json, &request.model),
246 LlmProvider::Anthropic => translate_from_anthropic(response_json, &request.model),
247 LlmProvider::Ternlang | LlmProvider::Aws => {
248 serde_json::from_value::<MessageResponse>(response_json).map_err(ApiError::from)?
249 }
250 _ if self.provider.is_openai_compat() => translate_from_openai(response_json, &request.model),
251 _ => serde_json::from_value::<MessageResponse>(response_json).map_err(ApiError::from)?,
252 };
253
254 if final_response.request_id.is_none() {
255 final_response.request_id = request_id;
256 }
257 Ok(final_response)
258 }
259
260 pub async fn stream_message(
261 &mut self,
262 request: &MessageRequest,
263 ) -> Result<MessageStream, ApiError> {
264 if self.provider == LlmProvider::Google {
266 let non_stream_req = MessageRequest { stream: false, ..request.clone() };
267 let buffered = self.send_message(&non_stream_req).await?;
268 return Ok(MessageStream::from_buffered_response(buffered));
269 }
270 let response = self
271 .send_with_retry(&request.clone().with_streaming())
272 .await?;
273 Ok(MessageStream {
274 _request_id: request_id_from_headers(response.headers()),
275 response: Some(response),
276 parser: SseParser::new(),
277 pending: VecDeque::new(),
278 done: false,
279 })
280 }
281
282 async fn send_with_retry(
283 &self,
284 request: &MessageRequest,
285 ) -> Result<reqwest::Response, ApiError> {
286 let mut attempts = 0;
287 let mut last_error: Option<ApiError>;
288
289 loop {
290 attempts += 1;
291 match self.send_raw_request(request).await {
292 Ok(response) => match expect_success(response).await {
293 Ok(response) => return Ok(response),
294 Err(error) if error.is_retryable() && attempts <= self.max_retries => {
295 last_error = Some(error);
296 }
297 Err(error) => return Err(error),
298 },
299 Err(error) if error.is_retryable() && attempts <= self.max_retries => {
300 last_error = Some(error);
301 }
302 Err(error) => return Err(error),
303 }
304
305 if attempts > self.max_retries {
306 break;
307 }
308
309 tokio::time::sleep(self.backoff_for_attempt(attempts)?).await;
310 }
311
312 Err(ApiError::RetriesExhausted {
313 attempts,
314 last_error: Box::new(last_error.unwrap_or(ApiError::Auth("Max retries exceeded without error capture".to_string()))),
315 })
316 }
317
318 fn backoff_for_attempt(&self, attempt: u32) -> Result<Duration, ApiError> {
319 let multiplier = 2_u32.pow(attempt.saturating_sub(1));
320 Ok(self
321 .initial_backoff
322 .checked_mul(multiplier)
323 .map_or(self.max_backoff, |delay| delay.min(self.max_backoff)))
324 }
325
326 pub async fn list_remote_models(&self) -> Result<Vec<String>, ApiError> {
327 match self.provider {
328 LlmProvider::Google => {
329 let url = format!("{}/v1beta/models?key={}", self.base_url.trim_end_matches('/'), self.auth.api_key().unwrap_or(""));
330 let res = self.http.get(&url).send().await.map_err(ApiError::from)?;
331 let json: serde_json::Value = res.json().await.map_err(ApiError::from)?;
332
333 let mut models = vec![];
334 if let Some(list) = json.get("models").and_then(|m| m.as_array()) {
335 for m in list {
336 if let Some(name) = m.get("name").and_then(|n| n.as_str()) {
337 models.push(name.replace("models/", ""));
338 }
339 }
340 }
341 Ok(models)
342 }
343 LlmProvider::OpenAi | LlmProvider::Ollama | LlmProvider::Xai => {
344 let url = format!("{}/v1/models", self.base_url.trim_end_matches('/'));
345 let res = self.auth.apply(self.provider, self.http.get(&url)).send().await.map_err(ApiError::from)?;
346 let json: serde_json::Value = res.json().await.map_err(ApiError::from)?;
347
348 let mut models = vec![];
349 if let Some(list) = json.get("data").and_then(|m| m.as_array()) {
350 for m in list {
351 if let Some(id) = m.get("id").and_then(|i| i.as_str()) {
352 models.push(id.to_string());
353 }
354 }
355 }
356 Ok(models)
357 }
358 _ => Ok(vec![])
359 }
360 }
361
362 pub async fn create_embeddings(&self, model: &str, input: &[String]) -> Result<Vec<Vec<f32>>, ApiError> {
363 if self.provider.is_openai_compat() || self.provider == LlmProvider::Ternlang {
364 let url = format!("{}/v1/embeddings", self.base_url.trim_end_matches('/'));
365 let req = EmbeddingRequest {
366 model: model.to_string(),
367 input: input.to_vec(),
368 };
369
370 let res = self.auth.apply(self.provider, self.http.post(&url))
371 .json(&req)
372 .send()
373 .await
374 .map_err(ApiError::from)?;
375
376 if !res.status().is_success() {
377 let status = res.status();
378 let body = res.text().await.unwrap_or_default();
379 return Err(ApiError::ProviderError { status, body });
380 }
381
382 let data: EmbeddingResponse = res.json().await.map_err(ApiError::from)?;
383 Ok(data.data.into_iter().map(|d| d.embedding).collect())
384 } else {
385 Err(ApiError::Config(format!("Embeddings not yet supported for provider {:?}", self.provider)))
386 }
387 }
388
389 pub async fn exchange_oauth_code(
390 &self,
391 _config: OAuthConfig,
392 _request: &OAuthTokenExchangeRequest,
393 ) -> Result<RuntimeTokenSet, ApiError> {
394 Ok(RuntimeTokenSet {
395 access_token: "dummy_token".to_string(),
396 refresh_token: None,
397 expires_at: None,
398 scopes: vec![],
399 })
400 }
401
402 pub async fn check_for_updates(&self) -> Result<Option<String>, ApiError> {
404 let url = "https://crates.io/api/v1/crates/albert-cli";
405 let res = self.http.get(url)
407 .header("User-Agent", "albert-cli (https://github.com/eriirfos-eng/ternary-intelligence-stack)")
408 .send()
409 .await
410 .map_err(ApiError::from)?;
411
412 if !res.status().is_success() {
413 return Ok(None);
414 }
415
416 let json: serde_json::Value = res.json().await.map_err(ApiError::from)?;
417 let max_version = json.get("crate")
418 .and_then(|c| c.get("max_version"))
419 .and_then(|v| v.as_str())
420 .map(|s| s.to_string());
421
422 Ok(max_version)
423 }
424}
425
426#[derive(Debug)]
427pub struct MessageStream {
428 _request_id: Option<String>,
429 response: Option<reqwest::Response>,
430 parser: SseParser,
431 pending: VecDeque<StreamEvent>,
432 done: bool,
433}
434
435impl MessageStream {
436 fn from_buffered_response(response: MessageResponse) -> Self {
437 let mut pending = VecDeque::new();
438 pending.push_back(StreamEvent::MessageStart(MessageStartEvent {
439 message: response.clone(),
440 }));
441 for (i, block) in response.content.iter().enumerate() {
442 let index = i as u32;
443 pending.push_back(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
444 index,
445 content_block: block.clone(),
446 }));
447 if let OutputContentBlock::Text { text } = block {
448 pending.push_back(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
449 index,
450 delta: ContentBlockDelta::TextDelta { text: text.clone() },
451 }));
452 }
453 pending.push_back(StreamEvent::ContentBlockStop(ContentBlockStopEvent { index }));
454 }
455 pending.push_back(StreamEvent::MessageDelta(MessageDeltaEvent {
456 delta: MessageDelta {
457 stop_reason: response.stop_reason,
458 stop_sequence: response.stop_sequence,
459 },
460 usage: response.usage,
461 }));
462 pending.push_back(StreamEvent::MessageStop(MessageStopEvent {}));
463 Self {
464 _request_id: None,
465 response: None,
466 parser: SseParser::new(),
467 pending,
468 done: true,
469 }
470 }
471
472 pub async fn next_event(&mut self) -> Result<Option<StreamEvent>, ApiError> {
473 loop {
474 if let Some(event) = self.pending.pop_front() {
475 return Ok(Some(event));
476 }
477 if self.done { return Ok(None); }
478 match self.response.as_mut() {
479 None => {
480 self.done = true;
481 return Ok(None);
482 }
483 Some(response) => match response.chunk().await? {
484 None => {
485 self.done = true;
486 return Ok(None);
487 }
488 Some(chunk) => {
489 self.pending.extend(self.parser.push(&chunk)?);
490 }
491 },
492 }
493 }
494 }
495}
496
497fn translate_to_anthropic(request: &MessageRequest) -> serde_json::Value {
498 use serde_json::json;
499 let messages: Vec<serde_json::Value> = request.messages.iter().map(|msg| {
500 let content: Vec<serde_json::Value> = msg.content.iter().map(|block| {
501 match block {
502 InputContentBlock::Text { text } => json!({ "type": "text", "text": text }),
503 InputContentBlock::ToolUse { id, name, input } => json!({
504 "type": "tool_use", "id": id, "name": name, "input": input
505 }),
506 InputContentBlock::ToolResult { tool_use_id, content, is_error } => {
507 let text = content.iter().filter_map(|c| {
508 if let ToolResultContentBlock::Text { text } = c { Some(text.clone()) } else { None }
509 }).collect::<Vec<String>>().join("\n");
510 json!({
511 "type": "tool_result", "tool_use_id": tool_use_id, "content": text, "is_error": is_error
512 })
513 }
514 }
515 }).collect();
516 json!({ "role": msg.role, "content": content })
517 }).collect();
518
519 let mut body = json!({
520 "model": request.model,
521 "messages": messages,
522 "max_tokens": request.max_tokens.unwrap_or(4096),
523 "stream": request.stream
524 });
525 if let Some(system) = &request.system { body["system"] = json!(system); }
526 if let Some(tools) = &request.tools {
527 body["tools"] = json!(tools.iter().map(|t| {
528 json!({ "name": t.name, "description": t.description, "input_schema": t.input_schema })
529 }).collect::<Vec<_>>());
530 }
531 body
532}
533
534fn translate_to_openai(request: &MessageRequest) -> serde_json::Value {
535 use serde_json::json;
536 let mut messages = vec![];
537 if let Some(system) = &request.system { messages.push(json!({ "role": "system", "content": system })); }
538
539 for msg in &request.messages {
540 let mut content_text = String::new();
541 let mut tool_calls = vec![];
542
543 for block in &msg.content {
544 match block {
545 InputContentBlock::Text { text } => content_text.push_str(text),
546 InputContentBlock::ToolUse { id, name, input } => {
547 tool_calls.push(json!({
548 "id": id, "type": "function", "function": { "name": name, "arguments": input.to_string() }
549 }));
550 }
551 InputContentBlock::ToolResult { tool_use_id, content, .. } => {
552 let text = content.iter().filter_map(|c| {
553 if let ToolResultContentBlock::Text { text } = c { Some(text.clone()) } else { None }
554 }).collect::<Vec<String>>().join("\n");
555 messages.push(json!({ "role": "tool", "tool_call_id": tool_use_id, "content": text }));
556 }
557 }
558 }
559
560 if !content_text.is_empty() || !tool_calls.is_empty() {
561 let mut m = json!({ "role": msg.role });
562 if !content_text.is_empty() { m["content"] = json!(content_text); }
563 if !tool_calls.is_empty() { m["tool_calls"] = json!(tool_calls); }
564 messages.push(m);
565 }
566 }
567
568 let mut body = json!({ "model": request.model, "messages": messages, "stream": request.stream });
569 if let Some(max) = request.max_tokens {
570 body["max_tokens"] = json!(max);
571 }
572 if let Some(tools) = &request.tools {
573 body["tools"] = json!(tools.iter().map(|t| {
574 json!({ "type": "function", "function": { "name": t.name, "description": t.description, "parameters": t.input_schema } })
575 }).collect::<Vec<_>>());
576 }
577 body
578}
579
580fn strip_gemini_unsupported_schema_fields(schema: serde_json::Value) -> serde_json::Value {
582 match schema {
583 serde_json::Value::Object(mut map) => {
584 map.remove("additionalProperties");
585 if let Some(serde_json::Value::Array(types)) = map.get("type") {
587 let first = types.iter()
588 .find(|t| t.as_str() != Some("null"))
589 .or_else(|| types.first())
590 .cloned()
591 .unwrap_or(serde_json::Value::String("string".to_string()));
592 map.insert("type".to_string(), first);
593 }
594 let cleaned = map.into_iter()
595 .map(|(k, v)| (k, strip_gemini_unsupported_schema_fields(v)))
596 .collect();
597 serde_json::Value::Object(cleaned)
598 }
599 serde_json::Value::Array(arr) => {
600 serde_json::Value::Array(arr.into_iter().map(strip_gemini_unsupported_schema_fields).collect())
601 }
602 other => other,
603 }
604}
605
606fn translate_to_gemini(request: &MessageRequest) -> serde_json::Value {
607 use serde_json::json;
608 let contents: Vec<serde_json::Value> = request.messages.iter().map(|msg| {
609 let role = if msg.role == "assistant" { "model" } else { "user" };
610 let parts: Vec<serde_json::Value> = msg.content.iter().map(|block| {
611 match block {
612 InputContentBlock::Text { text } => json!({ "text": text }),
613 InputContentBlock::ToolUse { name, input, .. } => json!({ "functionCall": { "name": name, "args": input } }),
614 InputContentBlock::ToolResult { tool_use_id, content, .. } => {
615 let text = content.iter().filter_map(|c| {
616 if let ToolResultContentBlock::Text { text } = c { Some(text.clone()) } else { None }
617 }).collect::<Vec<String>>().join("\n");
618 json!({ "functionResponse": { "name": tool_use_id, "response": { "result": text } } })
619 }
620 }
621 }).collect();
622 json!({ "role": role, "parts": parts })
623 }).collect();
624
625 let mut body = json!({ "contents": contents });
626 if let Some(system) = &request.system { body["systemInstruction"] = json!({ "parts": [{ "text": system }] }); }
627 if let Some(tools) = &request.tools {
628 let declarations: Vec<serde_json::Value> = tools.iter().map(|t| {
629 json!({ "name": t.name, "description": t.description, "parameters": strip_gemini_unsupported_schema_fields(t.input_schema.clone()) })
630 }).collect();
631 body["tools"] = json!([{ "functionDeclarations": declarations }]);
632 }
633 if let Some(max) = request.max_tokens {
634 body["generationConfig"] = json!({ "maxOutputTokens": max });
635 }
636 body
637}
638
639fn translate_from_anthropic(response: serde_json::Value, model: &str) -> MessageResponse {
640 let mut content = vec![];
641 if let Some(blocks) = response.get("content").and_then(|c| c.as_array()) {
642 for block in blocks {
643 match block.get("type").and_then(|t| t.as_str()) {
644 Some("text") => if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
645 content.push(OutputContentBlock::Text { text: text.to_string() });
646 },
647 Some("tool_use") => if let (Some(id), Some(name), Some(input)) = (
648 block.get("id").and_then(|i| i.as_str()),
649 block.get("name").and_then(|n| n.as_str()),
650 block.get("input")
651 ) {
652 content.push(OutputContentBlock::ToolUse { id: id.to_string(), name: name.to_string(), input: input.clone() });
653 },
654 _ => {}
655 }
656 }
657 }
658 let mut usage = Usage { input_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 };
659 if let Some(u) = response.get("usage") {
660 usage.input_tokens = u.get("input_tokens").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
661 usage.output_tokens = u.get("output_tokens").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
662 }
663 MessageResponse {
664 id: response.get("id").and_then(|i| i.as_str()).unwrap_or("anthropic-response").to_string(),
665 kind: "message".to_string(), role: "assistant".to_string(), content, model: model.to_string(),
666 stop_reason: response.get("stop_reason").and_then(|s| s.as_str()).map(|s| s.to_string()),
667 stop_sequence: None, usage, request_id: None,
668 }
669}
670
671fn translate_from_openai(response: serde_json::Value, model: &str) -> MessageResponse {
672 let mut content = vec![];
673 if let Some(choices) = response.get("choices").and_then(|c| c.as_array()) {
674 if let Some(choice) = choices.first() {
675 if let Some(message) = choice.get("message") {
676 if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
677 content.push(OutputContentBlock::Text { text: text.to_string() });
678 }
679 if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
680 for call in tool_calls {
681 if let (Some(id), Some(name), Some(args_str)) = (
682 call.get("id").and_then(|i| i.as_str()),
683 call.get("function").and_then(|f| f.get("name")).and_then(|n| n.as_str()),
684 call.get("function").and_then(|f| f.get("arguments")).and_then(|a| a.as_str())
685 ) {
686 if let Ok(args) = serde_json::from_str(args_str) {
687 content.push(OutputContentBlock::ToolUse { id: id.to_string(), name: name.to_string(), input: args });
688 }
689 }
690 }
691 }
692 }
693 }
694 }
695 let mut usage = Usage { input_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 };
696 if let Some(u) = response.get("usage") {
697 usage.input_tokens = u.get("prompt_tokens").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
698 usage.output_tokens = u.get("completion_tokens").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
699 }
700 MessageResponse {
701 id: response.get("id").and_then(|i| i.as_str()).unwrap_or("openai-response").to_string(),
702 kind: "message".to_string(), role: "assistant".to_string(), content, model: model.to_string(),
703 stop_reason: Some("end_turn".to_string()), stop_sequence: None, usage, request_id: None,
704 }
705}
706
707fn translate_from_gemini(response: serde_json::Value, model: &str) -> MessageResponse {
708 let mut content = vec![];
709 if let Some(candidates) = response.get("candidates").and_then(|c| c.as_array()) {
710 if let Some(candidate) = candidates.first() {
711 if let Some(parts) = candidate.get("content").and_then(|c| c.get("parts")).and_then(|p| p.as_array()) {
712 for part in parts {
713 if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
714 content.push(OutputContentBlock::Text { text: text.to_string() });
715 }
716 if let Some(call) = part.get("functionCall") {
717 if let (Some(name), Some(args)) = (call.get("name").and_then(|n| n.as_str()), call.get("args")) {
718 content.push(OutputContentBlock::ToolUse { id: name.to_string(), name: name.to_string(), input: args.clone() });
719 }
720 }
721 }
722 }
723 }
724 }
725 let mut usage = Usage { input_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 };
726 if let Some(u) = response.get("usageMetadata") {
727 usage.input_tokens = u.get("promptTokenCount").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
728 usage.output_tokens = u.get("candidatesTokenCount").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
729 }
730 MessageResponse {
731 id: "gemini-response".to_string(), kind: "message".to_string(), role: "assistant".to_string(),
732 content, model: model.to_string(), stop_reason: Some("end_turn".to_string()),
733 stop_sequence: None, usage, request_id: None,
734 }
735}
736
737pub fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
738 match std::env::var(key) {
739 Ok(value) if !value.is_empty() => Ok(Some(value)),
740 Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
741 Err(error) => Err(ApiError::from(error)),
742 }
743}
744
745pub fn read_base_url() -> String {
746 std::env::var("TERNLANG_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
747}
748
749fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
750 headers
751 .get(REQUEST_ID_HEADER)
752 .or_else(|| headers.get(ALT_REQUEST_ID_HEADER))
753 .and_then(|value| value.to_str().ok())
754 .map(ToOwned::to_owned)
755}
756
757async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response, ApiError> {
758 if response.status().is_success() {
759 return Ok(response);
760 }
761 let status = response.status();
762 let body = response.text().await.unwrap_or_default();
763 Err(ApiError::Auth(format!("HTTP {status}: {body}")))
764}
765
766pub fn resolve_startup_auth_source() -> Result<AuthSource, ApiError> {
767 if let Some(api_key) = read_env_non_empty("TERNLANG_API_KEY")? {
768 return Ok(AuthSource::ApiKey(api_key));
769 }
770 Ok(AuthSource::None)
771}
772
773pub fn resolve_auth_for_provider(provider: LlmProvider) -> Result<AuthSource, ApiError> {
775 if matches!(provider, LlmProvider::Ollama | LlmProvider::LmStudio | LlmProvider::OpenAiCompat) {
777 return Ok(AuthSource::None);
778 }
779 let env_var = provider.env_var();
780 let key = if provider == LlmProvider::Google {
781 read_env_non_empty("GEMINI_API_KEY").ok().flatten()
783 .or_else(|| read_env_non_empty("GOOGLE_API_KEY").ok().flatten())
784 } else if env_var.is_empty() {
785 None
786 } else {
787 read_env_non_empty(env_var)?
788 };
789 Ok(key.map_or(AuthSource::None, AuthSource::ApiKey))
790}
791
792pub fn detect_provider_and_model_from_env() -> Option<(LlmProvider, &'static str)> {
795 let env_set = |var: &str| std::env::var(var).ok().filter(|v| !v.is_empty()).is_some();
796 if env_set("ANTHROPIC_API_KEY") {
797 return Some((LlmProvider::Anthropic, "claude-sonnet-4-6"));
798 }
799 if env_set("GEMINI_API_KEY") || env_set("GOOGLE_API_KEY") {
800 return Some((LlmProvider::Google, "gemini-2.5-flash"));
801 }
802 if env_set("OPENAI_API_KEY") {
803 return Some((LlmProvider::OpenAi, "gpt-4o-mini"));
804 }
805 if env_set("XAI_API_KEY") {
806 return Some((LlmProvider::Xai, "grok-3-mini"));
807 }
808 if env_set("GROQ_API_KEY") {
809 return Some((LlmProvider::Groq, "llama-3.3-70b-versatile"));
810 }
811 if env_set("MISTRAL_API_KEY") {
812 return Some((LlmProvider::Mistral, "mistral-large-latest"));
813 }
814 if env_set("DEEPSEEK_API_KEY") {
815 return Some((LlmProvider::DeepSeek, "deepseek-chat"));
816 }
817 if env_set("TOGETHER_API_KEY") {
818 return Some((LlmProvider::Together, "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo"));
819 }
820 if env_set("OPENROUTER_API_KEY") {
821 return Some((LlmProvider::OpenRouter, "openai/gpt-4o-mini"));
822 }
823 if env_set("PERPLEXITY_API_KEY") {
824 return Some((LlmProvider::Perplexity, "sonar-pro"));
825 }
826 if env_set("FIREWORKS_API_KEY") {
827 return Some((LlmProvider::Fireworks, "accounts/fireworks/models/llama-v3p1-70b-instruct"));
828 }
829 if env_set("COHERE_API_KEY") {
830 return Some((LlmProvider::Cohere, "command-r-plus"));
831 }
832 if env_set("CEREBRAS_API_KEY") {
833 return Some((LlmProvider::Cerebras, "llama3.3-70b"));
834 }
835 if env_set("NOVITA_API_KEY") {
836 return Some((LlmProvider::Novita, "meta-llama/llama-3.1-70b-instruct"));
837 }
838 if env_set("SAMBANOVA_API_KEY") {
839 return Some((LlmProvider::SambaNova, "Meta-Llama-3.3-70B-Instruct"));
840 }
841 if env_set("NVIDIA_API_KEY") {
842 return Some((LlmProvider::NvidiaNim, "nvidia/llama-3.1-nemotron-70b-instruct"));
843 }
844 if env_set("HUGGINGFACE_API_KEY") {
845 return Some((LlmProvider::HuggingFace, "meta-llama/Meta-Llama-3-8B-Instruct"));
846 }
847 if env_set("GITHUB_TOKEN") {
848 return Some((LlmProvider::GitHub, "gpt-4o-mini"));
849 }
850 None
851}
852
853#[derive(serde::Deserialize)]
854pub struct OAuthConfig {}