Skip to main content

albert_api/
client.rs

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    // ── First-party ──────────────────────────────────────────────────────────
16    Ternlang,
17    Anthropic,
18    OpenAi,
19    Google,
20    Xai,
21    // ── Inference cloud (all OpenAI-compatible) ───────────────────────────────
22    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    // ── Regional foundation models (OpenAI-compatible) ───────────────────────
36    Zhipu,
37    MiniMax,
38    Qwen,
39    // ── Enterprise cloud ─────────────────────────────────────────────────────
40    Azure,
41    Aws,
42    // ── Aggregators ──────────────────────────────────────────────────────────
43    HuggingFace,
44    GitHub,
45    // ── Local / offline ──────────────────────────────────────────────────────
46    Ollama,
47    LmStudio,
48    // ── Generic OpenAI-compatible (user-configured base URL) ─────────────────
49    OpenAiCompat,
50}
51
52impl LlmProvider {
53    /// Returns `true` for every provider that speaks the OpenAI /v1/chat/completions wire format.
54    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            // All OpenAI-compat providers share this path
97            _ => "/v1/chat/completions",
98        }
99    }
100
101    /// Canonical env-var name for this provider's API key (used for display / docs).
102    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        // Gemini SSE format differs from Anthropic's — use non-streaming and wrap events
265        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    /// Check crates.io for the latest version of albert-cli.
403    pub async fn check_for_updates(&self) -> Result<Option<String>, ApiError> {
404        let url = "https://crates.io/api/v1/crates/albert-cli";
405        // Crates.io requires a User-Agent header
406        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
580/// Gemini only supports a subset of JSON Schema — strip/normalize fields it rejects.
581fn 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            // "type": ["string", "null"] → "type": "string" (Gemini requires a single type string)
586            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
773/// Read the standard env var for `provider` and return the appropriate auth.
774pub fn resolve_auth_for_provider(provider: LlmProvider) -> Result<AuthSource, ApiError> {
775    // No-auth local providers
776    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        // Google accepts either GEMINI_API_KEY or GOOGLE_API_KEY
782        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
792/// Scan well-known env vars and return the first available (provider, default-model) pair.
793/// Returns None if no recognised key is set (Ollama/LM Studio local are not detected here).
794pub 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 {}