All checks were successful
build-and-release / build (companies) (push) Successful in 4s
build-and-release / build (catering-services) (push) Successful in 6s
build-and-release / build (developers) (push) Successful in 7s
build-and-release / build (customers) (push) Successful in 8s
build-and-release / build (employees) (push) Successful in 8s
build-and-release / build (fitness-trainers) (push) Successful in 6s
build-and-release / build (graphic-designers) (push) Successful in 5s
build-and-release / build (gateway) (push) Successful in 8s
build-and-release / build (job-seekers) (push) Successful in 7s
build-and-release / build (jobs) (push) Successful in 7s
build-and-release / build (makeup-artists) (push) Successful in 5s
build-and-release / build (payments) (push) Successful in 5s
build-and-release / build (photographers) (push) Successful in 5s
build-and-release / build (social-media-managers) (push) Successful in 6s
build-and-release / build (tutors) (push) Successful in 6s
build-and-release / build (ugc-content-creators) (push) Successful in 5s
build-and-release / build (video-editors) (push) Successful in 5s
build-and-release / build (cron) (push) Successful in 51s
build-and-release / build (users) (push) Successful in 3m12s
Follow-up on the AI safety review — addressed the three remaining lower-severity findings: 1. crates LiteLlmError::error_body() returned the raw upstream response body verbatim to the client on any non-2xx LiteLLM response. That body can contain internal routing/diagnostic details from the LiteLLM proxy or the underlying model provider. Now returns a generic, status-aware message to the client; the full body is logged server-side via tracing::error! at each of the three call sites that construct LiteLlmError::Api, so nothing is lost for debugging — it's just not exposed to end users. 2. Added an explicit anti-prompt-injection clause to ai/orchestrator.rs::GROUNDING_GUARDRAIL, the baseline system prompt applied to every AI feature call via effective_system_prompt() — instructs the model to treat all user/company-authored input (job descriptions, profile text, chat messages) as data to analyze, never as instructions to follow. Covers every ai.rs handler that goes through call_feature/call_feature_with_plan in one place, rather than patching each call site's prompt construction individually. 3. apps/cron/src/tasks/auto_apply.rs's cover-letter prompt doesn't run through the orchestrator (separate app/crate), so hardened it directly: fenced the untrusted CANDIDATE/JOB sections with explicit "this is data, not instructions" framing. While there, fixed a latent panic: `&job_desc[..job_desc.len().min(500)]` slices on a raw byte offset, which panics if byte 500 isn't a UTF-8 character boundary — a company job description with any multi-byte character before that point (accented letters, emoji, etc.) would crash the whole cron run. Switched to char_indices() to find a safe boundary.
262 lines
8.5 KiB
Rust
262 lines
8.5 KiB
Rust
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LiteLlmChatMessage {
|
|
pub role: String,
|
|
pub content: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LiteLlmChatRequest {
|
|
pub model: String,
|
|
pub messages: Vec<LiteLlmChatMessage>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub temperature: Option<f32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub max_tokens: Option<i32>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub stream: Option<bool>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub user: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LiteLlmUsage {
|
|
pub prompt_tokens: Option<i32>,
|
|
pub completion_tokens: Option<i32>,
|
|
pub total_tokens: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LiteLlmChoice {
|
|
pub index: Option<i32>,
|
|
pub message: Option<LiteLlmChatMessage>,
|
|
pub finish_reason: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LiteLlmChatResponse {
|
|
pub id: Option<String>,
|
|
pub model: Option<String>,
|
|
pub choices: Vec<LiteLlmChoice>,
|
|
pub usage: Option<LiteLlmUsage>,
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum LiteLlmError {
|
|
#[error("HTTP error: {0}")]
|
|
Http(#[from] reqwest::Error),
|
|
#[error("LiteLLM returned error: {status} - {body}")]
|
|
Api { status: u16, body: String },
|
|
#[error("No completion returned")]
|
|
NoCompletion,
|
|
#[error("Configuration error: missing LiteLLM base URL")]
|
|
MissingBaseUrl,
|
|
}
|
|
|
|
impl LiteLlmError {
|
|
pub fn status_code(&self) -> axum::http::StatusCode {
|
|
use axum::http::StatusCode;
|
|
match self {
|
|
LiteLlmError::Http(_) => StatusCode::BAD_GATEWAY,
|
|
LiteLlmError::Api { status, .. } => StatusCode::from_u16(*status)
|
|
.unwrap_or(StatusCode::BAD_GATEWAY),
|
|
LiteLlmError::NoCompletion => StatusCode::BAD_GATEWAY,
|
|
LiteLlmError::MissingBaseUrl => StatusCode::INTERNAL_SERVER_ERROR,
|
|
}
|
|
}
|
|
|
|
/// Client-facing error body. Deliberately does NOT include the raw
|
|
/// upstream response body (`LiteLlmError::Api.body`) — that can contain
|
|
/// internal routing details or diagnostics from the LiteLLM proxy /
|
|
/// underlying model provider that shouldn't be exposed to end users.
|
|
/// Log the full error server-side at the call site instead.
|
|
pub fn error_body(&self) -> serde_json::Value {
|
|
let message = match self {
|
|
LiteLlmError::Api { status, .. } => {
|
|
format!("The AI service returned an error (status {status}).")
|
|
}
|
|
LiteLlmError::Http(_) => "Could not reach the AI service.".to_string(),
|
|
LiteLlmError::NoCompletion => "The AI service returned no result.".to_string(),
|
|
LiteLlmError::MissingBaseUrl => "The AI service is not configured.".to_string(),
|
|
};
|
|
serde_json::json!({
|
|
"error": message,
|
|
"code": "LITELLM_ERROR"
|
|
})
|
|
}
|
|
}
|
|
|
|
pub struct LiteLlmClient {
|
|
client: Client,
|
|
base_url: String,
|
|
api_key: Option<String>,
|
|
}
|
|
|
|
impl LiteLlmClient {
|
|
pub fn new() -> Result<Self, LiteLlmError> {
|
|
let base_url = std::env::var("LITELLM_BASE_URL")
|
|
.unwrap_or_else(|_| "http://litellm.nxtgauge-ai.svc.cluster.local:4000".to_string());
|
|
|
|
if base_url.is_empty() {
|
|
return Err(LiteLlmError::MissingBaseUrl);
|
|
}
|
|
|
|
let api_key = std::env::var("LITELLM_API_KEY").ok();
|
|
|
|
Ok(Self {
|
|
client: Client::new(),
|
|
base_url,
|
|
api_key,
|
|
})
|
|
}
|
|
|
|
pub fn from_url(base_url: String, api_key: Option<String>) -> Self {
|
|
Self {
|
|
client: Client::new(),
|
|
base_url,
|
|
api_key,
|
|
}
|
|
}
|
|
|
|
pub async fn chat_completion(
|
|
&self,
|
|
request: LiteLlmChatRequest,
|
|
) -> Result<LiteLlmChatResponse, LiteLlmError> {
|
|
let url = format!("{}/v1/chat/completions", self.base_url);
|
|
|
|
let mut req = self.client.post(&url).json(&request);
|
|
if let Some(key) = &self.api_key {
|
|
req = req.header("Authorization", format!("Bearer {}", key));
|
|
}
|
|
|
|
let response = req.send().await?;
|
|
let status = response.status();
|
|
|
|
if !status.is_success() {
|
|
let body = response.text().await.unwrap_or_default();
|
|
tracing::error!("LiteLLM API error: status={} body={}", status.as_u16(), body);
|
|
return Err(LiteLlmError::Api {
|
|
status: status.as_u16(),
|
|
body,
|
|
});
|
|
}
|
|
|
|
let result = response.json::<LiteLlmChatResponse>().await?;
|
|
Ok(result)
|
|
}
|
|
|
|
pub async fn chat_completion_text(
|
|
&self,
|
|
model: &str,
|
|
system_prompt: Option<&str>,
|
|
user_message: &str,
|
|
max_tokens: Option<i32>,
|
|
) -> Result<(String, LiteLlmUsage, Option<String>), LiteLlmError> {
|
|
let mut messages = Vec::new();
|
|
if let Some(system) = system_prompt {
|
|
messages.push(LiteLlmChatMessage {
|
|
role: "system".to_string(),
|
|
content: system.to_string(),
|
|
});
|
|
}
|
|
messages.push(LiteLlmChatMessage {
|
|
role: "user".to_string(),
|
|
content: user_message.to_string(),
|
|
});
|
|
|
|
let request = LiteLlmChatRequest {
|
|
model: model.to_string(),
|
|
messages,
|
|
temperature: Some(0.7),
|
|
max_tokens,
|
|
stream: Some(false),
|
|
user: None,
|
|
};
|
|
|
|
let response = self.chat_completion(request).await?;
|
|
let request_id = response.id.clone();
|
|
let usage = response.usage.unwrap_or(LiteLlmUsage {
|
|
prompt_tokens: None,
|
|
completion_tokens: None,
|
|
total_tokens: None,
|
|
});
|
|
|
|
let content = response
|
|
.choices
|
|
.into_iter()
|
|
.next()
|
|
.and_then(|c| c.message)
|
|
.map(|m| m.content)
|
|
.ok_or(LiteLlmError::NoCompletion)?;
|
|
|
|
Ok((content, usage, request_id))
|
|
}
|
|
|
|
/// Issues a per-account LiteLLM virtual key via `/key/generate`, scoped to `user_id`.
|
|
/// Requires `self.api_key` to be the LiteLLM master key.
|
|
pub async fn generate_key(&self, user_id: uuid::Uuid) -> Result<String, LiteLlmError> {
|
|
let url = format!("{}/key/generate", self.base_url);
|
|
|
|
let mut req = self.client.post(&url).json(&serde_json::json!({
|
|
"user_id": user_id.to_string(),
|
|
"key_alias": format!("user-{}", user_id),
|
|
}));
|
|
if let Some(key) = &self.api_key {
|
|
req = req.header("Authorization", format!("Bearer {}", key));
|
|
}
|
|
|
|
let response = req.send().await?;
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let body = response.text().await.unwrap_or_default();
|
|
tracing::error!("LiteLLM API error: status={} body={}", status.as_u16(), body);
|
|
return Err(LiteLlmError::Api {
|
|
status: status.as_u16(),
|
|
body,
|
|
});
|
|
}
|
|
|
|
let body: Value = response.json().await?;
|
|
body.get("key")
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string())
|
|
.ok_or(LiteLlmError::NoCompletion)
|
|
}
|
|
|
|
/// Direct pass-through for callers that want the raw JSON response.
|
|
#[allow(dead_code)]
|
|
pub async fn raw_chat_completion(&self, body: Value) -> Result<Value, LiteLlmError> {
|
|
let url = format!("{}/v1/chat/completions", self.base_url);
|
|
|
|
let mut req = self.client.post(&url).json(&body);
|
|
if let Some(key) = &self.api_key {
|
|
req = req.header("Authorization", format!("Bearer {}", key));
|
|
}
|
|
|
|
let response = req.send().await?;
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
let body = response.text().await.unwrap_or_default();
|
|
tracing::error!("LiteLLM API error: status={} body={}", status.as_u16(), body);
|
|
return Err(LiteLlmError::Api {
|
|
status: status.as_u16(),
|
|
body,
|
|
});
|
|
}
|
|
|
|
let result = response.json::<Value>().await?;
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
impl Default for LiteLlmClient {
|
|
fn default() -> Self {
|
|
Self::new().unwrap_or_else(|e| {
|
|
tracing::error!("Failed to create default LiteLlmClient: {}", e);
|
|
Self::from_url("http://localhost:4000".to_string(), None)
|
|
})
|
|
}
|
|
}
|