use std::time::Duration;
use serde::Deserialize;
use tracing::Instrument as _;
use crate::error::LlmError;
#[derive(Debug, Clone, Deserialize)]
pub struct CocoonHealth {
#[serde(default)]
pub proxy_connected: bool,
#[serde(default)]
pub worker_count: u32,
#[serde(default)]
pub ton_balance: Option<f64>,
}
pub struct CocoonClient {
base_url: String,
access_hash: Option<String>,
client: reqwest::Client,
timeout: Duration,
}
impl std::fmt::Debug for CocoonClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CocoonClient")
.field("base_url", &self.base_url)
.field(
"access_hash",
&self.access_hash.as_ref().map(|_| "<redacted>"),
)
.field("timeout", &self.timeout)
.finish_non_exhaustive()
}
}
impl CocoonClient {
#[must_use]
pub fn new(
base_url: impl Into<String>,
access_hash: Option<String>,
timeout: Duration,
) -> Self {
let mut url = base_url.into();
while url.ends_with('/') {
url.pop();
}
let client = crate::http::llm_client(timeout.as_secs());
Self {
base_url: url,
access_hash,
client,
timeout,
}
}
pub async fn health_check(&self) -> Result<CocoonHealth, LlmError> {
async {
let url = format!("{}/stats", self.base_url);
let response = self.client.get(&url).send().await.map_err(|e| {
tracing::warn!(error = %e, "cocoon sidecar unreachable");
LlmError::Unavailable
})?;
let text = response.text().await.map_err(LlmError::Http)?;
let health: CocoonHealth = serde_json::from_str(&text)?;
tracing::debug!(
proxy_connected = health.proxy_connected,
worker_count = health.worker_count,
"done"
);
Ok(health)
}
.instrument(tracing::info_span!("llm.cocoon.health"))
.await
}
pub async fn list_models(&self) -> Result<Vec<String>, LlmError> {
async {
let url = format!("{}/v1/models", self.base_url);
let response = self
.client
.get(&url)
.send()
.await
.map_err(|_| LlmError::Unavailable)?;
let text = response.text().await.map_err(LlmError::Http)?;
let parsed: ModelsResponse = serde_json::from_str(&text)?;
Ok(parsed.data.into_iter().map(|m| m.id).collect())
}
.instrument(tracing::info_span!("llm.cocoon.models"))
.await
}
pub async fn post_multipart(
&self,
path: &str,
form: reqwest::multipart::Form,
) -> Result<reqwest::Response, LlmError> {
let span = tracing::info_span!("llm.cocoon.request", path);
async {
let url = format!("{}{path}", self.base_url);
let mut req = self.client.post(&url).multipart(form);
if let Some(ref hash) = self.access_hash {
req = req.header("X-Access-Hash", hash.as_str());
}
req.send().await.map_err(|e| {
tracing::warn!(error = %e, "cocoon multipart HTTP error");
LlmError::Unavailable
})
}
.instrument(span)
.await
}
pub async fn post(&self, path: &str, body: &[u8]) -> Result<reqwest::Response, LlmError> {
let span = tracing::info_span!("llm.cocoon.request", path);
async {
let url = format!("{}{path}", self.base_url);
let mut req = self
.client
.post(&url)
.header("Content-Type", "application/json")
.body(body.to_vec());
if let Some(ref hash) = self.access_hash {
req = req.header("X-Access-Hash", hash.as_str());
}
req.send().await.map_err(|e| {
tracing::warn!(error = %e, "cocoon HTTP error (may be mid-stream drop)");
LlmError::Unavailable
})
}
.instrument(span)
.await
}
}
#[derive(Deserialize)]
struct ModelsResponse {
data: Vec<ModelEntry>,
}
#[derive(Deserialize)]
struct ModelEntry {
id: String,
}