Resolve per-account LiteLLM key instead of always using the shared master key
Some checks are pending
build-and-release / build (push) Waiting to run
Some checks are pending
build-and-release / build (push) Waiting to run
Adds UserKeyClient, which fetches (and caches) an account's LiteLLM virtual key from the users service's internal endpoint. AiProvider gains complete_as_user(), and LiteLLMProvider resolves the caller's key when available, falling back to the master key otherwise so existing behavior is unchanged where no per-account key is wired up yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
520806cb08
commit
526e12ac12
5 changed files with 146 additions and 12 deletions
23
src/main.rs
23
src/main.rs
|
|
@ -28,6 +28,7 @@ use providers::help_center::nxtgauge_help_center_provider::NxtgaugeHelpCenterPro
|
||||||
use providers::llm::fake_provider::FakeAiProvider;
|
use providers::llm::fake_provider::FakeAiProvider;
|
||||||
use providers::llm::litellm_provider::LiteLLMProvider;
|
use providers::llm::litellm_provider::LiteLLMProvider;
|
||||||
use providers::llm::ollama_provider::OllamaAiProvider;
|
use providers::llm::ollama_provider::OllamaAiProvider;
|
||||||
|
use providers::llm::user_key_client::UserKeyClient;
|
||||||
use providers::tickets::nxtgauge_ticket_provider::NxtgaugeTicketProvider;
|
use providers::tickets::nxtgauge_ticket_provider::NxtgaugeTicketProvider;
|
||||||
use providers::llm::ai_provider::AiProvider;
|
use providers::llm::ai_provider::AiProvider;
|
||||||
use retrieval::embeddings::ollama_embedding_provider::OllamaEmbeddingProvider;
|
use retrieval::embeddings::ollama_embedding_provider::OllamaEmbeddingProvider;
|
||||||
|
|
@ -48,6 +49,8 @@ async fn main() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let http_client = reqwest::Client::new();
|
||||||
|
|
||||||
let ai_provider: Arc<dyn AiProvider> = match cfg.llm_provider.as_str() {
|
let ai_provider: Arc<dyn AiProvider> = match cfg.llm_provider.as_str() {
|
||||||
"fake" => {
|
"fake" => {
|
||||||
info!("Using Fake AI provider for testing");
|
info!("Using Fake AI provider for testing");
|
||||||
|
|
@ -55,11 +58,19 @@ async fn main() {
|
||||||
}
|
}
|
||||||
"litellm" => {
|
"litellm" => {
|
||||||
info!("Using LiteLLM provider with model {}", cfg.litellm_model);
|
info!("Using LiteLLM provider with model {}", cfg.litellm_model);
|
||||||
Arc::new(LiteLLMProvider::new(
|
let user_key_client = Arc::new(UserKeyClient::new(
|
||||||
cfg.litellm_base_url.clone(),
|
http_client.clone(),
|
||||||
cfg.litellm_api_key.clone(),
|
cfg.nxtgauge_users_url.clone(),
|
||||||
cfg.litellm_model.clone(),
|
cfg.ai_service_key.clone(),
|
||||||
)) as Arc<dyn AiProvider>
|
));
|
||||||
|
Arc::new(
|
||||||
|
LiteLLMProvider::new(
|
||||||
|
cfg.litellm_base_url.clone(),
|
||||||
|
cfg.litellm_api_key.clone(),
|
||||||
|
cfg.litellm_model.clone(),
|
||||||
|
)
|
||||||
|
.with_user_key_client(user_key_client),
|
||||||
|
) as Arc<dyn AiProvider>
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
info!("Using Ollama provider with model {}", cfg.ollama_chat_model);
|
info!("Using Ollama provider with model {}", cfg.ollama_chat_model);
|
||||||
|
|
@ -75,8 +86,6 @@ async fn main() {
|
||||||
cfg.ollama_embed_model.clone(),
|
cfg.ollama_embed_model.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let http_client = reqwest::Client::new();
|
|
||||||
|
|
||||||
let help_center_provider = Arc::new(NxtgaugeHelpCenterProvider::new(
|
let help_center_provider = Arc::new(NxtgaugeHelpCenterProvider::new(
|
||||||
http_client.clone(),
|
http_client.clone(),
|
||||||
cfg.nxtgauge_users_url.clone(),
|
cfg.nxtgauge_users_url.clone(),
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,19 @@ pub trait AiProvider: Send + Sync {
|
||||||
) -> Result<String, AppError> {
|
) -> Result<String, AppError> {
|
||||||
self.complete(system_prompt, user_prompt).await
|
self.complete(system_prompt, user_prompt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Same as `complete_as`, but scoped to a specific account's LiteLLM key
|
||||||
|
/// when the provider supports per-account credentials (`user_id` is the
|
||||||
|
/// nxtgauge account id). Providers that don't support this (Ollama, the
|
||||||
|
/// fake test provider) or that have no key for this user fall back to
|
||||||
|
/// `complete_as`, which uses the shared/master credential.
|
||||||
|
async fn complete_as_user(
|
||||||
|
&self,
|
||||||
|
_user_id: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
system_prompt: &str,
|
||||||
|
user_prompt: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
self.complete_as(model, system_prompt, user_prompt).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{error::AppError, providers::llm::ai_provider::AiProvider};
|
use crate::{
|
||||||
|
error::AppError,
|
||||||
|
providers::llm::{ai_provider::AiProvider, user_key_client::UserKeyClient},
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct LiteLLMProvider {
|
pub struct LiteLLMProvider {
|
||||||
|
|
@ -10,6 +15,7 @@ pub struct LiteLLMProvider {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
api_key: String,
|
api_key: String,
|
||||||
model: String,
|
model: String,
|
||||||
|
user_key_client: Option<Arc<UserKeyClient>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LiteLLMProvider {
|
impl LiteLLMProvider {
|
||||||
|
|
@ -19,9 +25,16 @@ impl LiteLLMProvider {
|
||||||
base_url,
|
base_url,
|
||||||
api_key,
|
api_key,
|
||||||
model,
|
model,
|
||||||
|
user_key_client: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enables per-account key resolution for `complete_as_user` calls.
|
||||||
|
pub fn with_user_key_client(mut self, user_key_client: Arc<UserKeyClient>) -> Self {
|
||||||
|
self.user_key_client = Some(user_key_client);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn fallback_response(user_prompt: &str) -> String {
|
fn fallback_response(user_prompt: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"LiteLLM model is unavailable right now. I captured your request so workflow can continue: {}",
|
"LiteLLM model is unavailable right now. I captured your request so workflow can continue: {}",
|
||||||
|
|
@ -29,7 +42,13 @@ impl LiteLLMProvider {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn chat(&self, model: &str, system_prompt: &str, user_prompt: &str) -> Result<String, AppError> {
|
async fn chat(
|
||||||
|
&self,
|
||||||
|
api_key: &str,
|
||||||
|
model: &str,
|
||||||
|
system_prompt: &str,
|
||||||
|
user_prompt: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/'));
|
||||||
|
|
||||||
let payload = ChatCompletionRequest {
|
let payload = ChatCompletionRequest {
|
||||||
|
|
@ -51,7 +70,7 @@ impl LiteLLMProvider {
|
||||||
let res = self
|
let res = self
|
||||||
.client
|
.client
|
||||||
.post(url)
|
.post(url)
|
||||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
.header("Authorization", format!("Bearer {}", api_key))
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.json(&payload)
|
.json(&payload)
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -118,7 +137,7 @@ struct Message {
|
||||||
impl AiProvider for LiteLLMProvider {
|
impl AiProvider for LiteLLMProvider {
|
||||||
async fn complete(&self, system_prompt: &str, user_prompt: &str) -> Result<String, AppError> {
|
async fn complete(&self, system_prompt: &str, user_prompt: &str) -> Result<String, AppError> {
|
||||||
let model = self.model.clone();
|
let model = self.model.clone();
|
||||||
self.chat(&model, system_prompt, user_prompt).await
|
self.chat(&self.api_key, &model, system_prompt, user_prompt).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn complete_as(
|
async fn complete_as(
|
||||||
|
|
@ -127,6 +146,21 @@ impl AiProvider for LiteLLMProvider {
|
||||||
system_prompt: &str,
|
system_prompt: &str,
|
||||||
user_prompt: &str,
|
user_prompt: &str,
|
||||||
) -> Result<String, AppError> {
|
) -> Result<String, AppError> {
|
||||||
self.chat(model, system_prompt, user_prompt).await
|
self.chat(&self.api_key, model, system_prompt, user_prompt).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn complete_as_user(
|
||||||
|
&self,
|
||||||
|
user_id: Option<&str>,
|
||||||
|
model: &str,
|
||||||
|
system_prompt: &str,
|
||||||
|
user_prompt: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let key = match (user_id, &self.user_key_client) {
|
||||||
|
(Some(uid), Some(client)) => client.get_key(uid).await,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
self.chat(key.as_deref().unwrap_or(&self.api_key), model, system_prompt, user_prompt)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@ pub mod ai_provider;
|
||||||
pub mod fake_provider;
|
pub mod fake_provider;
|
||||||
pub mod litellm_provider;
|
pub mod litellm_provider;
|
||||||
pub mod ollama_provider;
|
pub mod ollama_provider;
|
||||||
|
pub mod user_key_client;
|
||||||
|
|
|
||||||
75
src/providers/llm/user_key_client.rs
Normal file
75
src/providers/llm/user_key_client.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// Fetches (and caches) a per-account LiteLLM virtual key from the users
|
||||||
|
/// service's internal `/internal/users/{id}/llm-key` endpoint, so LLM calls
|
||||||
|
/// can be attributed/billed to the account making them instead of always
|
||||||
|
/// using the shared LiteLLM master key.
|
||||||
|
pub struct UserKeyClient {
|
||||||
|
client: Client,
|
||||||
|
base_url: String,
|
||||||
|
ai_service_key: String,
|
||||||
|
cache: RwLock<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct LlmKeyResponse {
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserKeyClient {
|
||||||
|
pub fn new(client: Client, base_url: String, ai_service_key: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
base_url,
|
||||||
|
ai_service_key,
|
||||||
|
cache: RwLock::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the account's LiteLLM key, or `None` if it can't be fetched
|
||||||
|
/// (users service unreachable, key not configured, etc). Callers should
|
||||||
|
/// fall back to the shared master key on `None`.
|
||||||
|
pub async fn get_key(&self, user_id: &str) -> Option<String> {
|
||||||
|
if let Some(key) = self.cache.read().await.get(user_id) {
|
||||||
|
return Some(key.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.ai_service_key.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/internal/users/{}/llm-key",
|
||||||
|
self.base_url.trim_end_matches('/'),
|
||||||
|
user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(&url)
|
||||||
|
.header("X-AI-Service-Key", &self.ai_service_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
tracing::warn!(
|
||||||
|
user_id,
|
||||||
|
status = %response.status(),
|
||||||
|
"Failed to fetch per-user LiteLLM key; falling back to master key"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: LlmKeyResponse = response.json().await.ok()?;
|
||||||
|
self.cache
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(user_id.to_string(), body.key.clone());
|
||||||
|
Some(body.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue