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 exchange_oauth_code(
363        &self,
364        _config: OAuthConfig,
365        _request: &OAuthTokenExchangeRequest,
366    ) -> Result<RuntimeTokenSet, ApiError> {
367        Ok(RuntimeTokenSet {
368            access_token: "dummy_token".to_string(),
369            refresh_token: None,
370            expires_at: None,
371            scopes: vec![],
372        })
373    }
374}
375
376#[derive(Debug)]
377pub struct MessageStream {
378    _request_id: Option<String>,
379    response: Option<reqwest::Response>,
380    parser: SseParser,
381    pending: VecDeque<StreamEvent>,
382    done: bool,
383}
384
385impl MessageStream {
386    fn from_buffered_response(response: MessageResponse) -> Self {
387        let mut pending = VecDeque::new();
388        pending.push_back(StreamEvent::MessageStart(MessageStartEvent {
389            message: response.clone(),
390        }));
391        for (i, block) in response.content.iter().enumerate() {
392            let index = i as u32;
393            pending.push_back(StreamEvent::ContentBlockStart(ContentBlockStartEvent {
394                index,
395                content_block: block.clone(),
396            }));
397            if let OutputContentBlock::Text { text } = block {
398                pending.push_back(StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
399                    index,
400                    delta: ContentBlockDelta::TextDelta { text: text.clone() },
401                }));
402            }
403            pending.push_back(StreamEvent::ContentBlockStop(ContentBlockStopEvent { index }));
404        }
405        pending.push_back(StreamEvent::MessageDelta(MessageDeltaEvent {
406            delta: MessageDelta {
407                stop_reason: response.stop_reason,
408                stop_sequence: response.stop_sequence,
409            },
410            usage: response.usage,
411        }));
412        pending.push_back(StreamEvent::MessageStop(MessageStopEvent {}));
413        Self {
414            _request_id: None,
415            response: None,
416            parser: SseParser::new(),
417            pending,
418            done: true,
419        }
420    }
421
422    pub async fn next_event(&mut self) -> Result<Option<StreamEvent>, ApiError> {
423        loop {
424            if let Some(event) = self.pending.pop_front() {
425                return Ok(Some(event));
426            }
427            if self.done { return Ok(None); }
428            match self.response.as_mut() {
429                None => {
430                    self.done = true;
431                    return Ok(None);
432                }
433                Some(response) => match response.chunk().await? {
434                    None => {
435                        self.done = true;
436                        return Ok(None);
437                    }
438                    Some(chunk) => {
439                        self.pending.extend(self.parser.push(&chunk)?);
440                    }
441                },
442            }
443        }
444    }
445}
446
447fn translate_to_anthropic(request: &MessageRequest) -> serde_json::Value {
448    use serde_json::json;
449    let messages: Vec<serde_json::Value> = request.messages.iter().map(|msg| {
450        let content: Vec<serde_json::Value> = msg.content.iter().map(|block| {
451            match block {
452                InputContentBlock::Text { text } => json!({ "type": "text", "text": text }),
453                InputContentBlock::ToolUse { id, name, input } => json!({
454                    "type": "tool_use", "id": id, "name": name, "input": input
455                }),
456                InputContentBlock::ToolResult { tool_use_id, content, is_error } => {
457                    let text = content.iter().filter_map(|c| {
458                        if let ToolResultContentBlock::Text { text } = c { Some(text.clone()) } else { None }
459                    }).collect::<Vec<String>>().join("\n");
460                    json!({
461                        "type": "tool_result", "tool_use_id": tool_use_id, "content": text, "is_error": is_error
462                    })
463                }
464            }
465        }).collect();
466        json!({ "role": msg.role, "content": content })
467    }).collect();
468
469    let mut body = json!({
470        "model": request.model,
471        "messages": messages,
472        "max_tokens": request.max_tokens.unwrap_or(4096),
473        "stream": request.stream
474    });
475    if let Some(system) = &request.system { body["system"] = json!(system); }
476    if let Some(tools) = &request.tools {
477        body["tools"] = json!(tools.iter().map(|t| {
478            json!({ "name": t.name, "description": t.description, "input_schema": t.input_schema })
479        }).collect::<Vec<_>>());
480    }
481    body
482}
483
484fn translate_to_openai(request: &MessageRequest) -> serde_json::Value {
485    use serde_json::json;
486    let mut messages = vec![];
487    if let Some(system) = &request.system { messages.push(json!({ "role": "system", "content": system })); }
488
489    for msg in &request.messages {
490        let mut content_text = String::new();
491        let mut tool_calls = vec![];
492
493        for block in &msg.content {
494            match block {
495                InputContentBlock::Text { text } => content_text.push_str(text),
496                InputContentBlock::ToolUse { id, name, input } => {
497                    tool_calls.push(json!({
498                        "id": id, "type": "function", "function": { "name": name, "arguments": input.to_string() }
499                    }));
500                }
501                InputContentBlock::ToolResult { tool_use_id, content, .. } => {
502                    let text = content.iter().filter_map(|c| {
503                        if let ToolResultContentBlock::Text { text } = c { Some(text.clone()) } else { None }
504                    }).collect::<Vec<String>>().join("\n");
505                    messages.push(json!({ "role": "tool", "tool_call_id": tool_use_id, "content": text }));
506                }
507            }
508        }
509
510        if !content_text.is_empty() || !tool_calls.is_empty() {
511            let mut m = json!({ "role": msg.role });
512            if !content_text.is_empty() { m["content"] = json!(content_text); }
513            if !tool_calls.is_empty() { m["tool_calls"] = json!(tool_calls); }
514            messages.push(m);
515        }
516    }
517
518    let mut body = json!({ "model": request.model, "messages": messages, "stream": request.stream });
519    if let Some(max) = request.max_tokens {
520        body["max_tokens"] = json!(max);
521    }
522    if let Some(tools) = &request.tools {
523        body["tools"] = json!(tools.iter().map(|t| {
524            json!({ "type": "function", "function": { "name": t.name, "description": t.description, "parameters": t.input_schema } })
525        }).collect::<Vec<_>>());
526    }
527    body
528}
529
530/// Gemini only supports a subset of JSON Schema — strip/normalize fields it rejects.
531fn strip_gemini_unsupported_schema_fields(schema: serde_json::Value) -> serde_json::Value {
532    match schema {
533        serde_json::Value::Object(mut map) => {
534            map.remove("additionalProperties");
535            // "type": ["string", "null"] → "type": "string" (Gemini requires a single type string)
536            if let Some(serde_json::Value::Array(types)) = map.get("type") {
537                let first = types.iter()
538                    .find(|t| t.as_str() != Some("null"))
539                    .or_else(|| types.first())
540                    .cloned()
541                    .unwrap_or(serde_json::Value::String("string".to_string()));
542                map.insert("type".to_string(), first);
543            }
544            let cleaned = map.into_iter()
545                .map(|(k, v)| (k, strip_gemini_unsupported_schema_fields(v)))
546                .collect();
547            serde_json::Value::Object(cleaned)
548        }
549        serde_json::Value::Array(arr) => {
550            serde_json::Value::Array(arr.into_iter().map(strip_gemini_unsupported_schema_fields).collect())
551        }
552        other => other,
553    }
554}
555
556fn translate_to_gemini(request: &MessageRequest) -> serde_json::Value {
557    use serde_json::json;
558    let contents: Vec<serde_json::Value> = request.messages.iter().map(|msg| {
559        let role = if msg.role == "assistant" { "model" } else { "user" };
560        let parts: Vec<serde_json::Value> = msg.content.iter().map(|block| {
561            match block {
562                InputContentBlock::Text { text } => json!({ "text": text }),
563                InputContentBlock::ToolUse { name, input, .. } => json!({ "functionCall": { "name": name, "args": input } }),
564                InputContentBlock::ToolResult { tool_use_id, content, .. } => {
565                    let text = content.iter().filter_map(|c| {
566                        if let ToolResultContentBlock::Text { text } = c { Some(text.clone()) } else { None }
567                    }).collect::<Vec<String>>().join("\n");
568                    json!({ "functionResponse": { "name": tool_use_id, "response": { "result": text } } })
569                }
570            }
571        }).collect();
572        json!({ "role": role, "parts": parts })
573    }).collect();
574
575    let mut body = json!({ "contents": contents });
576    if let Some(system) = &request.system { body["systemInstruction"] = json!({ "parts": [{ "text": system }] }); }
577    if let Some(tools) = &request.tools {
578        let declarations: Vec<serde_json::Value> = tools.iter().map(|t| {
579            json!({ "name": t.name, "description": t.description, "parameters": strip_gemini_unsupported_schema_fields(t.input_schema.clone()) })
580        }).collect();
581        body["tools"] = json!([{ "functionDeclarations": declarations }]);
582    }
583    if let Some(max) = request.max_tokens {
584        body["generationConfig"] = json!({ "maxOutputTokens": max });
585    }
586    body
587}
588
589fn translate_from_anthropic(response: serde_json::Value, model: &str) -> MessageResponse {
590    let mut content = vec![];
591    if let Some(blocks) = response.get("content").and_then(|c| c.as_array()) {
592        for block in blocks {
593            match block.get("type").and_then(|t| t.as_str()) {
594                Some("text") => if let Some(text) = block.get("text").and_then(|t| t.as_str()) {
595                    content.push(OutputContentBlock::Text { text: text.to_string() });
596                },
597                Some("tool_use") => if let (Some(id), Some(name), Some(input)) = (
598                    block.get("id").and_then(|i| i.as_str()),
599                    block.get("name").and_then(|n| n.as_str()),
600                    block.get("input")
601                ) {
602                    content.push(OutputContentBlock::ToolUse { id: id.to_string(), name: name.to_string(), input: input.clone() });
603                },
604                _ => {}
605            }
606        }
607    }
608    let mut usage = Usage { input_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 };
609    if let Some(u) = response.get("usage") {
610        usage.input_tokens = u.get("input_tokens").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
611        usage.output_tokens = u.get("output_tokens").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
612    }
613    MessageResponse {
614        id: response.get("id").and_then(|i| i.as_str()).unwrap_or("anthropic-response").to_string(),
615        kind: "message".to_string(), role: "assistant".to_string(), content, model: model.to_string(),
616        stop_reason: response.get("stop_reason").and_then(|s| s.as_str()).map(|s| s.to_string()),
617        stop_sequence: None, usage, request_id: None,
618    }
619}
620
621fn translate_from_openai(response: serde_json::Value, model: &str) -> MessageResponse {
622    let mut content = vec![];
623    if let Some(choices) = response.get("choices").and_then(|c| c.as_array()) {
624        if let Some(choice) = choices.first() {
625            if let Some(message) = choice.get("message") {
626                if let Some(text) = message.get("content").and_then(|c| c.as_str()) {
627                    content.push(OutputContentBlock::Text { text: text.to_string() });
628                }
629                if let Some(tool_calls) = message.get("tool_calls").and_then(|t| t.as_array()) {
630                    for call in tool_calls {
631                        if let (Some(id), Some(name), Some(args_str)) = (
632                            call.get("id").and_then(|i| i.as_str()),
633                            call.get("function").and_then(|f| f.get("name")).and_then(|n| n.as_str()),
634                            call.get("function").and_then(|f| f.get("arguments")).and_then(|a| a.as_str())
635                        ) {
636                            if let Ok(args) = serde_json::from_str(args_str) {
637                                content.push(OutputContentBlock::ToolUse { id: id.to_string(), name: name.to_string(), input: args });
638                            }
639                        }
640                    }
641                }
642            }
643        }
644    }
645    let mut usage = Usage { input_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 };
646    if let Some(u) = response.get("usage") {
647        usage.input_tokens = u.get("prompt_tokens").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
648        usage.output_tokens = u.get("completion_tokens").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
649    }
650    MessageResponse {
651        id: response.get("id").and_then(|i| i.as_str()).unwrap_or("openai-response").to_string(),
652        kind: "message".to_string(), role: "assistant".to_string(), content, model: model.to_string(),
653        stop_reason: Some("end_turn".to_string()), stop_sequence: None, usage, request_id: None,
654    }
655}
656
657fn translate_from_gemini(response: serde_json::Value, model: &str) -> MessageResponse {
658    let mut content = vec![];
659    if let Some(candidates) = response.get("candidates").and_then(|c| c.as_array()) {
660        if let Some(candidate) = candidates.first() {
661            if let Some(parts) = candidate.get("content").and_then(|c| c.get("parts")).and_then(|p| p.as_array()) {
662                for part in parts {
663                    if let Some(text) = part.get("text").and_then(|t| t.as_str()) {
664                        content.push(OutputContentBlock::Text { text: text.to_string() });
665                    }
666                    if let Some(call) = part.get("functionCall") {
667                        if let (Some(name), Some(args)) = (call.get("name").and_then(|n| n.as_str()), call.get("args")) {
668                            content.push(OutputContentBlock::ToolUse { id: name.to_string(), name: name.to_string(), input: args.clone() });
669                        }
670                    }
671                }
672            }
673        }
674    }
675    let mut usage = Usage { input_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, output_tokens: 0 };
676    if let Some(u) = response.get("usageMetadata") {
677        usage.input_tokens = u.get("promptTokenCount").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
678        usage.output_tokens = u.get("candidatesTokenCount").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
679    }
680    MessageResponse {
681        id: "gemini-response".to_string(), kind: "message".to_string(), role: "assistant".to_string(),
682        content, model: model.to_string(), stop_reason: Some("end_turn".to_string()),
683        stop_sequence: None, usage, request_id: None,
684    }
685}
686
687pub fn read_env_non_empty(key: &str) -> Result<Option<String>, ApiError> {
688    match std::env::var(key) {
689        Ok(value) if !value.is_empty() => Ok(Some(value)),
690        Ok(_) | Err(std::env::VarError::NotPresent) => Ok(None),
691        Err(error) => Err(ApiError::from(error)),
692    }
693}
694
695pub fn read_base_url() -> String {
696    std::env::var("TERNLANG_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string())
697}
698
699fn request_id_from_headers(headers: &reqwest::header::HeaderMap) -> Option<String> {
700    headers
701        .get(REQUEST_ID_HEADER)
702        .or_else(|| headers.get(ALT_REQUEST_ID_HEADER))
703        .and_then(|value| value.to_str().ok())
704        .map(ToOwned::to_owned)
705}
706
707async fn expect_success(response: reqwest::Response) -> Result<reqwest::Response, ApiError> {
708    if response.status().is_success() {
709        return Ok(response);
710    }
711    let status = response.status();
712    let body = response.text().await.unwrap_or_default();
713    Err(ApiError::Auth(format!("HTTP {status}: {body}")))
714}
715
716pub fn resolve_startup_auth_source() -> Result<AuthSource, ApiError> {
717    if let Some(api_key) = read_env_non_empty("TERNLANG_API_KEY")? {
718        return Ok(AuthSource::ApiKey(api_key));
719    }
720    Ok(AuthSource::None)
721}
722
723/// Read the standard env var for `provider` and return the appropriate auth.
724pub fn resolve_auth_for_provider(provider: LlmProvider) -> Result<AuthSource, ApiError> {
725    // No-auth local providers
726    if matches!(provider, LlmProvider::Ollama | LlmProvider::LmStudio | LlmProvider::OpenAiCompat) {
727        return Ok(AuthSource::None);
728    }
729    let env_var = provider.env_var();
730    let key = if provider == LlmProvider::Google {
731        // Google accepts either GEMINI_API_KEY or GOOGLE_API_KEY
732        read_env_non_empty("GEMINI_API_KEY").ok().flatten()
733            .or_else(|| read_env_non_empty("GOOGLE_API_KEY").ok().flatten())
734    } else if env_var.is_empty() {
735        None
736    } else {
737        read_env_non_empty(env_var)?
738    };
739    Ok(key.map_or(AuthSource::None, AuthSource::ApiKey))
740}
741
742/// Scan well-known env vars and return the first available (provider, default-model) pair.
743/// Returns None if no recognised key is set (Ollama/LM Studio local are not detected here).
744pub fn detect_provider_and_model_from_env() -> Option<(LlmProvider, &'static str)> {
745    let env_set = |var: &str| std::env::var(var).ok().filter(|v| !v.is_empty()).is_some();
746    if env_set("ANTHROPIC_API_KEY") {
747        return Some((LlmProvider::Anthropic, "claude-sonnet-4-6"));
748    }
749    if env_set("GEMINI_API_KEY") || env_set("GOOGLE_API_KEY") {
750        return Some((LlmProvider::Google, "gemini-2.5-flash"));
751    }
752    if env_set("OPENAI_API_KEY") {
753        return Some((LlmProvider::OpenAi, "gpt-4o-mini"));
754    }
755    if env_set("XAI_API_KEY") {
756        return Some((LlmProvider::Xai, "grok-3-mini"));
757    }
758    if env_set("GROQ_API_KEY") {
759        return Some((LlmProvider::Groq, "llama-3.3-70b-versatile"));
760    }
761    if env_set("MISTRAL_API_KEY") {
762        return Some((LlmProvider::Mistral, "mistral-large-latest"));
763    }
764    if env_set("DEEPSEEK_API_KEY") {
765        return Some((LlmProvider::DeepSeek, "deepseek-chat"));
766    }
767    if env_set("TOGETHER_API_KEY") {
768        return Some((LlmProvider::Together, "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo"));
769    }
770    if env_set("OPENROUTER_API_KEY") {
771        return Some((LlmProvider::OpenRouter, "openai/gpt-4o-mini"));
772    }
773    if env_set("PERPLEXITY_API_KEY") {
774        return Some((LlmProvider::Perplexity, "sonar-pro"));
775    }
776    if env_set("FIREWORKS_API_KEY") {
777        return Some((LlmProvider::Fireworks, "accounts/fireworks/models/llama-v3p1-70b-instruct"));
778    }
779    if env_set("COHERE_API_KEY") {
780        return Some((LlmProvider::Cohere, "command-r-plus"));
781    }
782    if env_set("CEREBRAS_API_KEY") {
783        return Some((LlmProvider::Cerebras, "llama3.3-70b"));
784    }
785    if env_set("NOVITA_API_KEY") {
786        return Some((LlmProvider::Novita, "meta-llama/llama-3.1-70b-instruct"));
787    }
788    if env_set("SAMBANOVA_API_KEY") {
789        return Some((LlmProvider::SambaNova, "Meta-Llama-3.3-70B-Instruct"));
790    }
791    if env_set("NVIDIA_API_KEY") {
792        return Some((LlmProvider::NvidiaNim, "nvidia/llama-3.1-nemotron-70b-instruct"));
793    }
794    if env_set("HUGGINGFACE_API_KEY") {
795        return Some((LlmProvider::HuggingFace, "meta-llama/Meta-Llama-3-8B-Instruct"));
796    }
797    if env_set("GITHUB_TOKEN") {
798        return Some((LlmProvider::GitHub, "gpt-4o-mini"));
799    }
800    None
801}
802
803#[derive(serde::Deserialize)]
804pub struct OAuthConfig {}