use std::sync::Arc;
use std::time::Instant;
use async_trait::async_trait;
use futures::StreamExt;
use tokio::select;
use tokio_retry::RetryIf;
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use uuid::Uuid;
use chat_engine_sdk::error::PluginError;
use chat_engine_sdk::models::{
Capability, CapabilityValue, HealthStatus, Message, StreamingChunkEvent,
StreamingCompleteEvent, StreamingErrorEvent, StreamingEvent,
};
use chat_engine_sdk::plugin::{
ChatEngineBackendPlugin, MessagePluginCtx, PluginCallContext, PluginStream, SessionPluginCtx,
SessionPluginResponse, stream_from_events,
};
use crate::domain::llm_config::{
LlmMessageMetadata, LlmPluginConfig, LlmSummarizationSettings, validate_plugin_config,
};
pub const LLM_GATEWAY_PLUGIN_INSTANCE_ID: &str = "gtx.cf.chat_engine.llm_gateway_plugin.v1~";
pub const CAPABILITY_MODEL: &str = "model";
pub const CAPABILITY_TEMPERATURE: &str = "temperature";
pub const CAPABILITY_STREAM: &str = "stream";
pub const ERROR_PREFIX_CONTEXT_OVERFLOW: &str = "context_overflow:";
pub const ERROR_PREFIX_STREAM_INTERRUPTED: &str = "stream_interrupted:";
pub const ERROR_PREFIX_DEADLINE_EXCEEDED: &str = "deadline_exceeded:";
#[derive(Debug, Clone)]
pub struct ModelCapabilitySchema {
pub name: String,
pub schema: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct ModelCatalog {
pub model_ids: Vec<String>,
pub default_model_id: String,
}
#[derive(Debug)]
pub enum UpstreamEvent {
Chunk(String),
Complete(LlmMessageMetadata),
ContextOverflow(String),
StreamInterrupted(String),
Error(PluginError),
}
#[derive(Debug, Clone)]
pub struct LlmGatewayRequest {
pub session_id: Uuid,
pub message_id: Uuid,
pub model: Option<String>,
pub temperature: Option<f32>,
pub stream: bool,
pub messages: Vec<Message>,
}
pub type UpstreamStream = futures::stream::BoxStream<'static, Result<UpstreamEvent, PluginError>>;
#[async_trait]
pub trait LlmGatewayClient: Send + Sync + 'static {
async fn stream_chat(
&self,
config: &LlmPluginConfig,
request: LlmGatewayRequest,
) -> Result<UpstreamStream, PluginError>;
async fn summarize(
&self,
config: &LlmPluginConfig,
messages: Vec<Message>,
) -> Result<String, PluginError>;
}
#[async_trait]
pub trait ModelRegistryClient: Send + Sync + 'static {
async fn list_models(&self, config: &LlmPluginConfig) -> Result<ModelCatalog, PluginError>;
async fn model_capabilities(
&self,
config: &LlmPluginConfig,
model_id: &str,
) -> Result<Vec<ModelCapabilitySchema>, PluginError>;
}
#[derive(Debug, Clone)]
pub struct SummaryResult {
pub summary_text: String,
pub summarized_message_ids: Vec<Uuid>,
}
pub struct LlmGatewayPlugin {
plugin_instance_id: String,
gateway_client: Arc<dyn LlmGatewayClient>,
model_registry: Arc<dyn ModelRegistryClient>,
}
impl LlmGatewayPlugin {
#[must_use]
pub fn new(
gateway_client: Arc<dyn LlmGatewayClient>,
model_registry: Arc<dyn ModelRegistryClient>,
) -> Self {
Self {
plugin_instance_id: LLM_GATEWAY_PLUGIN_INSTANCE_ID.to_owned(),
gateway_client,
model_registry,
}
}
#[must_use]
pub fn with_instance_id(
plugin_instance_id: impl Into<String>,
gateway_client: Arc<dyn LlmGatewayClient>,
model_registry: Arc<dyn ModelRegistryClient>,
) -> Self {
Self {
plugin_instance_id: plugin_instance_id.into(),
gateway_client,
model_registry,
}
}
fn config_from_ctx(call_ctx: &PluginCallContext) -> Result<LlmPluginConfig, PluginError> {
let blob = call_ctx
.plugin_config
.as_ref()
.ok_or_else(|| PluginError::invalid_input("missing plugin_config"))?;
validate_plugin_config(blob)
}
async fn resolve_capabilities(
&self,
config: &LlmPluginConfig,
) -> Result<Vec<Capability>, PluginError> {
let catalog = self.model_registry.list_models(config).await?;
let preferred = config
.default_model
.clone()
.filter(|m| catalog.model_ids.iter().any(|x| x == m))
.unwrap_or_else(|| catalog.default_model_id.clone());
let mut caps = Vec::with_capacity(4);
caps.push(Capability {
name: CAPABILITY_MODEL.into(),
value: serde_json::json!({
"type": "enum",
"enum_values": catalog.model_ids,
"default_value": preferred,
}),
});
caps.push(Capability {
name: CAPABILITY_TEMPERATURE.into(),
value: serde_json::json!({
"type": "float",
"min": 0.0,
"max": 2.0,
"default_value": 0.7,
}),
});
caps.push(Capability {
name: CAPABILITY_STREAM.into(),
value: serde_json::json!({
"type": "bool",
"default_value": true,
}),
});
let model_caps = self
.model_registry
.model_capabilities(config, &preferred)
.await?;
for c in model_caps {
if matches!(
c.name.as_str(),
CAPABILITY_MODEL | CAPABILITY_TEMPERATURE | CAPABILITY_STREAM
) {
continue;
}
caps.push(Capability {
name: c.name,
value: c.schema,
});
}
Ok(caps)
}
async fn refresh_capabilities(
&self,
config: &LlmPluginConfig,
previous: Option<&Vec<CapabilityValue>>,
) -> Result<Vec<Capability>, PluginError> {
let _ = previous; self.resolve_capabilities(config).await
}
}
impl LlmGatewayPlugin {
fn transform_event(message_id: Uuid, ev: UpstreamEvent) -> StreamingEvent {
match ev {
UpstreamEvent::Chunk(chunk) => {
StreamingEvent::Chunk(StreamingChunkEvent { message_id, chunk })
}
UpstreamEvent::Complete(meta) => StreamingEvent::Complete(StreamingCompleteEvent {
message_id,
metadata: Some(meta.to_json()),
file_citations: vec![],
link_citations: vec![],
references: vec![],
}),
UpstreamEvent::ContextOverflow(detail) => StreamingEvent::Error(StreamingErrorEvent {
message_id,
error: format!("{ERROR_PREFIX_CONTEXT_OVERFLOW} {detail}"),
}),
UpstreamEvent::StreamInterrupted(detail) => {
StreamingEvent::Error(StreamingErrorEvent {
message_id,
error: format!("{ERROR_PREFIX_STREAM_INTERRUPTED} {detail}"),
})
}
UpstreamEvent::Error(err) => StreamingEvent::Error(StreamingErrorEvent {
message_id,
error: format!("internal: {err}"),
}),
}
}
fn deadline_exceeded_event(message_id: Uuid) -> StreamingEvent {
StreamingEvent::Error(StreamingErrorEvent {
message_id,
error: format!(
"{ERROR_PREFIX_DEADLINE_EXCEEDED} deadline elapsed before upstream call"
),
})
}
async fn with_retry<F, Fut, T>(
config: &LlmPluginConfig,
cancel: &CancellationToken,
op: F,
) -> Result<T, PluginError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, PluginError>>,
{
let max_attempts = config.effective_retry_count().max(1);
let base_delay = config.effective_retry_delay();
let strategy = std::iter::successors(Some(base_delay), |d| Some(d.saturating_mul(2)))
.take((max_attempts - 1) as usize);
let retryable = |e: &PluginError| e.is_retryable();
select! {
_ = cancel.cancelled() => Err(PluginError::transient("cancelled")),
r = RetryIf::start(strategy, op, retryable) => r,
}
}
}
#[async_trait]
impl ChatEngineBackendPlugin for LlmGatewayPlugin {
async fn on_session_type_configured(
&self,
ctx: SessionPluginCtx,
) -> Result<SessionPluginResponse, PluginError> {
let cfg = Self::config_from_ctx(&ctx.call_ctx)?;
debug!(
target = "chat_engine::llm_gateway",
session_type_id = %ctx.session_type_id,
summarization_enabled = cfg.summarization_enabled(),
retry_count = cfg.effective_retry_count(),
"llm gateway plugin config validated",
);
Ok(SessionPluginResponse::default())
}
async fn on_session_created(
&self,
ctx: SessionPluginCtx,
) -> Result<SessionPluginResponse, PluginError> {
let cfg = Self::config_from_ctx(&ctx.call_ctx)?;
let start = Instant::now();
let result = self.resolve_capabilities(&cfg).await;
debug!(
target = "chat_engine::llm_gateway",
session_type_id = %ctx.session_type_id,
session_id = ?ctx.session_id,
duration_ms = start.elapsed().as_millis() as u64,
ok = result.is_ok(),
"llm gateway: capabilities resolved",
);
result.map(SessionPluginResponse::from)
}
async fn on_session_updated(
&self,
ctx: SessionPluginCtx,
) -> Result<SessionPluginResponse, PluginError> {
let cfg = Self::config_from_ctx(&ctx.call_ctx)?;
let start = Instant::now();
let result = self
.refresh_capabilities(&cfg, ctx.call_ctx.enabled_capabilities.as_ref())
.await;
debug!(
target = "chat_engine::llm_gateway",
session_type_id = %ctx.session_type_id,
session_id = ?ctx.session_id,
duration_ms = start.elapsed().as_millis() as u64,
ok = result.is_ok(),
"llm gateway: capabilities refreshed",
);
result.map(SessionPluginResponse::from)
}
async fn on_message(&self, ctx: MessagePluginCtx) -> Result<PluginStream, PluginError> {
forward_to_gateway(
ctx,
Arc::clone(&self.gateway_client),
self.plugin_instance_id.clone(),
)
.await
}
async fn on_message_recreate(
&self,
ctx: MessagePluginCtx,
) -> Result<PluginStream, PluginError> {
forward_to_gateway(
ctx,
Arc::clone(&self.gateway_client),
self.plugin_instance_id.clone(),
)
.await
}
async fn on_session_summary(&self, ctx: SessionPluginCtx) -> Result<PluginStream, PluginError> {
let cfg = Self::config_from_ctx(&ctx.call_ctx)?;
let settings: LlmSummarizationSettings = cfg.summarization_settings.ok_or_else(|| {
PluginError::internal(
"summarization unsupported: LlmPluginConfig.summarization_settings is null",
)
})?;
let history: Vec<Message> = match ctx
.call_ctx
.plugin_config
.as_ref()
.and_then(|v| v.get("__summary_messages"))
{
Some(raw) => serde_json::from_value(raw.clone()).map_err(|e| {
PluginError::invalid_input_with("invalid __summary_messages payload", e)
})?,
None => Vec::new(),
};
if history.is_empty() {
return Err(PluginError::invalid_input(
"on_session_summary: empty history supplied",
));
}
let keep = settings.keep_count() as usize;
let split_at = history.len().saturating_sub(keep);
let to_summarize: Vec<Message> = history.iter().take(split_at).cloned().collect();
let summarized_ids: Vec<Uuid> = to_summarize.iter().map(|m| m.message_id).collect();
if to_summarize.is_empty() {
return Ok(stream_from_events(Vec::new()));
}
let cancel = ctx.call_ctx.cancel.clone();
let client = Arc::clone(&self.gateway_client);
let cfg_clone = cfg.clone();
let summary_text = Self::with_retry(&cfg_clone, &cancel, || {
let client = Arc::clone(&client);
let cfg = cfg_clone.clone();
let msgs = to_summarize.clone();
async move { client.summarize(&cfg, msgs).await }
})
.await?;
let session_id = ctx.session_id.unwrap_or_else(Uuid::nil);
let summary_event = StreamingEvent::Complete(StreamingCompleteEvent {
message_id: session_id,
metadata: Some(serde_json::json!({
"summary_text": summary_text,
"summarized_message_ids": summarized_ids,
})),
file_citations: vec![],
link_citations: vec![],
references: vec![],
});
debug!(
target = "chat_engine::llm_gateway",
session_id = %session_id,
messages_to_summarize = summarized_ids.len(),
"llm gateway: summary generated",
);
Ok(stream_from_events(vec![summary_event]))
}
async fn health_check(&self) -> Result<HealthStatus, PluginError> {
Ok(HealthStatus::Healthy)
}
fn plugin_instance_id(&self) -> &str {
&self.plugin_instance_id
}
}
async fn forward_to_gateway(
ctx: MessagePluginCtx,
client: Arc<dyn LlmGatewayClient>,
_plugin_instance_id: String,
) -> Result<PluginStream, PluginError> {
let cfg = LlmGatewayPlugin::config_from_ctx(&ctx.call_ctx)?;
if let Some(bad) = ctx.messages.iter().find(|m| m.is_hidden_from_backend) {
let err = PluginError::invalid_input(format!(
"message {} is hidden from backend but was forwarded to the plugin",
bad.message_id,
));
return Err(err);
}
if matches!(ctx.call_ctx.remaining(), Some(d) if d.is_zero()) {
let event = LlmGatewayPlugin::deadline_exceeded_event(ctx.message_id);
return Ok(stream_from_events(vec![event]));
}
let request = build_request(&ctx, &cfg);
let message_id = ctx.message_id;
let session_id = ctx.session_id;
let cancel = ctx.call_ctx.cancel.clone();
debug!(
target = "chat_engine::llm_gateway",
session_id = %session_id,
message_id = %message_id,
messages_in = ctx.messages.len(),
stream = request.stream,
"llm gateway: forwarding message",
);
let upstream = client.stream_chat(&cfg, request).await?;
let stream = futures::stream::unfold(
ForwardState {
upstream: Some(upstream),
cancel,
message_id,
bytes_received: 0,
finished: false,
},
|mut state| async move {
if state.finished {
return None;
}
let cancel = state.cancel.clone();
let upstream = state.upstream.as_mut()?;
let next = select! {
_ = cancel.cancelled() => None,
item = upstream.next() => item,
};
match next {
None => {
None
}
Some(Ok(UpstreamEvent::Chunk(chunk))) => {
state.bytes_received = state.bytes_received.saturating_add(chunk.len());
let ev = LlmGatewayPlugin::transform_event(
state.message_id,
UpstreamEvent::Chunk(chunk),
);
Some((Ok(ev), state))
}
Some(Ok(UpstreamEvent::Complete(meta))) => {
let ev = LlmGatewayPlugin::transform_event(
state.message_id,
UpstreamEvent::Complete(meta),
);
state.finished = true;
Some((Ok(ev), state))
}
Some(Ok(UpstreamEvent::ContextOverflow(detail))) => {
let ev = LlmGatewayPlugin::transform_event(
state.message_id,
UpstreamEvent::ContextOverflow(detail),
);
state.finished = true;
Some((Ok(ev), state))
}
Some(Ok(UpstreamEvent::StreamInterrupted(detail))) => {
warn!(
target = "chat_engine::llm_gateway",
message_id = %state.message_id,
bytes_received = state.bytes_received,
"llm gateway: stream interrupted",
);
let ev = LlmGatewayPlugin::transform_event(
state.message_id,
UpstreamEvent::StreamInterrupted(detail),
);
state.finished = true;
Some((Ok(ev), state))
}
Some(Ok(UpstreamEvent::Error(err))) => {
state.finished = true;
Some((Err(err), state))
}
Some(Err(err)) => {
state.finished = true;
Some((Err(err), state))
}
}
},
);
Ok(stream.boxed())
}
struct ForwardState {
upstream: Option<UpstreamStream>,
cancel: CancellationToken,
message_id: Uuid,
bytes_received: usize,
finished: bool,
}
fn build_request(ctx: &MessagePluginCtx, cfg: &LlmPluginConfig) -> LlmGatewayRequest {
let mut model: Option<String> = cfg.default_model.clone();
let mut temperature: Option<f32> = None;
let mut stream = true;
if let Some(values) = ctx.call_ctx.enabled_capabilities.as_ref() {
for v in values {
match v.name.as_str() {
CAPABILITY_MODEL => {
if let Some(s) = v.value.as_str() {
model = Some(s.to_owned());
}
}
CAPABILITY_TEMPERATURE => {
if let Some(f) = v.value.as_f64() {
#[allow(clippy::cast_possible_truncation)]
{
temperature = Some(f as f32);
}
}
}
CAPABILITY_STREAM => {
if let Some(b) = v.value.as_bool() {
stream = b;
}
}
_ => {}
}
}
}
LlmGatewayRequest {
session_id: ctx.session_id,
message_id: ctx.message_id,
model,
temperature,
stream,
messages: ctx.messages.clone(),
}
}
#[cfg(test)]
#[path = "llm_gateway_tests.rs"]
mod llm_gateway_tests;