feat(ai): add endpoints to chat with ai service (#8585)

Co-authored-by: Riddhiagrawal001 <riddhi.agrawal2112@gmail.com>
Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Apoorv Dixit
2025-07-17 01:30:13 +05:30
committed by GitHub
parent dcf867d96e
commit 3d60e6c4c8
26 changed files with 252 additions and 6 deletions

View File

@ -1134,3 +1134,7 @@ version = "HOSTNAME" # value of HOSTNAME from deployment which tells its
[platform] [platform]
enabled = true # Enable or disable platform features enabled = true # Enable or disable platform features
allow_connected_merchants = false # Enable or disable connected merchant account features allow_connected_merchants = false # Enable or disable connected merchant account features
[chat]
enabled = false # Enable or disable chat features
hyperswitch_ai_host = "http://0.0.0.0:8000" # Hyperswitch ai workflow host

View File

@ -381,3 +381,7 @@ connector_names = "connector_names" # Comma-separated list of allowed connec
host = "localhost" # Unified Connector Service Client Host host = "localhost" # Unified Connector Service Client Host
port = 8000 # Unified Connector Service Client Port port = 8000 # Unified Connector Service Client Port
connection_timeout = 10 # Connection Timeout Duration in Seconds connection_timeout = 10 # Connection Timeout Duration in Seconds
[chat]
enabled = false # Enable or disable chat features
hyperswitch_ai_host = "http://0.0.0.0:8000" # Hyperswitch ai workflow host

View File

@ -1226,3 +1226,7 @@ connector_names = "stripe, adyen" # Comma-separated list of allowe
[infra_values] [infra_values]
cluster = "CLUSTER" cluster = "CLUSTER"
version = "HOSTNAME" version = "HOSTNAME"
[chat]
enabled = false
hyperswitch_ai_host = "http://0.0.0.0:8000"

View File

@ -1109,6 +1109,10 @@ background_color = "#FFFFFF"
enabled = true enabled = true
allow_connected_merchants = true allow_connected_merchants = true
[chat]
enabled = false
hyperswitch_ai_host = "http://0.0.0.0:8000"
[authentication_providers] [authentication_providers]
click_to_pay = {connector_list = "adyen, cybersource"} click_to_pay = {connector_list = "adyen, cybersource"}

View File

@ -0,0 +1,18 @@
use common_utils::id_type;
use masking::Secret;
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
pub struct ChatRequest {
pub message: Secret<String>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct ChatResponse {
pub response: Secret<serde_json::Value>,
pub merchant_id: id_type::MerchantId,
pub status: String,
#[serde(skip_serializing)]
pub query_executed: Option<Secret<String>>,
#[serde(skip_serializing)]
pub row_count: Option<i32>,
}

View File

@ -1,4 +1,5 @@
pub mod apple_pay_certificates_migration; pub mod apple_pay_certificates_migration;
pub mod chat;
pub mod connector_onboarding; pub mod connector_onboarding;
pub mod customer; pub mod customer;
pub mod dispute; pub mod dispute;

View File

@ -0,0 +1,5 @@
use common_utils::events::{ApiEventMetric, ApiEventsType};
use crate::chat::{ChatRequest, ChatResponse};
common_utils::impl_api_event_type!(Chat, (ChatRequest, ChatResponse));

View File

@ -5,6 +5,7 @@ pub mod apple_pay_certificates_migration;
pub mod authentication; pub mod authentication;
pub mod blocklist; pub mod blocklist;
pub mod cards_info; pub mod cards_info;
pub mod chat;
pub mod conditional_configs; pub mod conditional_configs;
pub mod connector_enums; pub mod connector_enums;
pub mod connector_onboarding; pub mod connector_onboarding;

View File

@ -191,3 +191,6 @@ pub const METRICS_HOST_TAG_NAME: &str = "host";
/// API client request timeout (in seconds) /// API client request timeout (in seconds)
pub const REQUEST_TIME_OUT: u64 = 30; pub const REQUEST_TIME_OUT: u64 = 30;
/// API client request timeout for ai service (in seconds)
pub const REQUEST_TIME_OUT_FOR_AI_SERVICE: u64 = 120;

View File

@ -137,6 +137,7 @@ pub enum ApiEventsType {
profile_acquirer_id: id_type::ProfileAcquirerId, profile_acquirer_id: id_type::ProfileAcquirerId,
}, },
ThreeDsDecisionRule, ThreeDsDecisionRule,
Chat,
} }
impl ApiEventMetric for serde_json::Value {} impl ApiEventMetric for serde_json::Value {}

View File

@ -0,0 +1,15 @@
use common_utils::id_type;
use masking::Secret;
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
pub struct GetDataMessage {
pub message: Secret<String>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
pub struct HyperswitchAiDataRequest {
pub merchant_id: id_type::MerchantId,
pub profile_id: id_type::ProfileId,
pub org_id: id_type::OrganizationId,
pub query: GetDataMessage,
}

View File

@ -6,6 +6,7 @@ pub mod business_profile;
pub mod callback_mapper; pub mod callback_mapper;
pub mod card_testing_guard_data; pub mod card_testing_guard_data;
pub mod cards_info; pub mod cards_info;
pub mod chat;
pub mod connector_endpoints; pub mod connector_endpoints;
pub mod consts; pub mod consts;
pub mod customer; pub mod customer;

View File

@ -452,6 +452,7 @@ pub(crate) async fn fetch_raw_secrets(
Settings { Settings {
server: conf.server, server: conf.server,
chat: conf.chat,
master_database, master_database,
redis: conf.redis, redis: conf.redis,
log: conf.log, log: conf.log,

View File

@ -70,6 +70,7 @@ pub struct Settings<S: SecretState> {
pub server: Server, pub server: Server,
pub proxy: Proxy, pub proxy: Proxy,
pub env: Env, pub env: Env,
pub chat: ChatSettings,
pub master_database: SecretStateContainer<Database, S>, pub master_database: SecretStateContainer<Database, S>,
#[cfg(feature = "olap")] #[cfg(feature = "olap")]
pub replica_database: SecretStateContainer<Database, S>, pub replica_database: SecretStateContainer<Database, S>,
@ -195,6 +196,13 @@ pub struct Platform {
pub allow_connected_merchants: bool, pub allow_connected_merchants: bool,
} }
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(default)]
pub struct ChatSettings {
pub enabled: bool,
pub hyperswitch_ai_host: String,
}
#[derive(Debug, Clone, Default, Deserialize)] #[derive(Debug, Clone, Default, Deserialize)]
pub struct Multitenancy { pub struct Multitenancy {
pub tenants: TenantConfig, pub tenants: TenantConfig,
@ -1016,6 +1024,7 @@ impl Settings<SecuredSecret> {
self.secrets.get_inner().validate()?; self.secrets.get_inner().validate()?;
self.locker.validate()?; self.locker.validate()?;
self.connectors.validate("connectors")?; self.connectors.validate("connectors")?;
self.chat.validate()?;
self.cors.validate()?; self.cors.validate()?;

View File

@ -343,3 +343,15 @@ impl super::settings::OpenRouter {
) )
} }
} }
impl super::settings::ChatSettings {
pub fn validate(&self) -> Result<(), ApplicationError> {
use common_utils::fp_utils::when;
when(self.enabled && self.hyperswitch_ai_host.is_empty(), || {
Err(ApplicationError::InvalidConfigurationValueError(
"hyperswitch ai host must be set if chat is enabled".into(),
))
})
}
}

View File

@ -72,4 +72,5 @@ pub mod relay;
#[cfg(feature = "v2")] #[cfg(feature = "v2")]
pub mod revenue_recovery; pub mod revenue_recovery;
pub mod chat;
pub mod tokenization; pub mod tokenization;

View File

@ -0,0 +1,57 @@
use api_models::chat as chat_api;
use common_utils::{
consts,
errors::CustomResult,
request::{Method, RequestBuilder, RequestContent},
};
use error_stack::ResultExt;
use external_services::http_client;
use hyperswitch_domain_models::chat as chat_domain;
use router_env::{instrument, logger, tracing};
use crate::{
db::errors::chat::ChatErrors,
routes::SessionState,
services::{authentication as auth, ApplicationResponse},
};
#[instrument(skip_all)]
pub async fn get_data_from_hyperswitch_ai_workflow(
state: SessionState,
user_from_token: auth::UserFromToken,
req: chat_api::ChatRequest,
) -> CustomResult<ApplicationResponse<chat_api::ChatResponse>, ChatErrors> {
let url = format!("{}/webhook", state.conf.chat.hyperswitch_ai_host);
let request_body = chat_domain::HyperswitchAiDataRequest {
query: chat_domain::GetDataMessage {
message: req.message,
},
org_id: user_from_token.org_id,
merchant_id: user_from_token.merchant_id,
profile_id: user_from_token.profile_id,
};
logger::info!("Request for AI service: {:?}", request_body);
let request = RequestBuilder::new()
.method(Method::Post)
.url(&url)
.attach_default_headers()
.set_body(RequestContent::Json(Box::new(request_body.clone())))
.build();
let response = http_client::send_request(
&state.conf.proxy,
request,
Some(consts::REQUEST_TIME_OUT_FOR_AI_SERVICE),
)
.await
.change_context(ChatErrors::InternalServerError)
.attach_printable("Error when sending request to AI service")?
.json::<_>()
.await
.change_context(ChatErrors::InternalServerError)
.attach_printable("Error when deserializing response from AI service")?;
Ok(ApplicationResponse::Json(response))
}

View File

@ -1,3 +1,4 @@
pub mod chat;
pub mod customers_error_response; pub mod customers_error_response;
pub mod error_handlers; pub mod error_handlers;
pub mod transformers; pub mod transformers;

View File

@ -0,0 +1,37 @@
#[derive(Debug, thiserror::Error)]
pub enum ChatErrors {
#[error("User InternalServerError")]
InternalServerError,
#[error("Missing Config error")]
MissingConfigError,
#[error("Chat response deserialization failed")]
ChatResponseDeserializationFailed,
}
impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for ChatErrors {
fn switch(&self) -> api_models::errors::types::ApiErrorResponse {
use api_models::errors::types::{ApiError, ApiErrorResponse as AER};
let sub_code = "AI";
match self {
Self::InternalServerError => {
AER::InternalServerError(ApiError::new("HE", 0, self.get_error_message(), None))
}
Self::MissingConfigError => {
AER::InternalServerError(ApiError::new(sub_code, 1, self.get_error_message(), None))
}
Self::ChatResponseDeserializationFailed => {
AER::BadRequest(ApiError::new(sub_code, 2, self.get_error_message(), None))
}
}
}
}
impl ChatErrors {
pub fn get_error_message(&self) -> String {
match self {
Self::InternalServerError => "Something went wrong".to_string(),
Self::MissingConfigError => "Missing webhook url".to_string(),
Self::ChatResponseDeserializationFailed => "Failed to parse chat response".to_string(),
}
}
}

View File

@ -189,7 +189,8 @@ pub fn mk_app(
.service(routes::MerchantAccount::server(state.clone())) .service(routes::MerchantAccount::server(state.clone()))
.service(routes::User::server(state.clone())) .service(routes::User::server(state.clone()))
.service(routes::ApiKeys::server(state.clone())) .service(routes::ApiKeys::server(state.clone()))
.service(routes::Routing::server(state.clone())); .service(routes::Routing::server(state.clone()))
.service(routes::Chat::server(state.clone()));
#[cfg(feature = "v1")] #[cfg(feature = "v1")]
{ {

View File

@ -75,6 +75,8 @@ pub mod process_tracker;
#[cfg(feature = "v2")] #[cfg(feature = "v2")]
pub mod proxy; pub mod proxy;
pub mod chat;
#[cfg(feature = "dummy_connector")] #[cfg(feature = "dummy_connector")]
pub use self::app::DummyConnector; pub use self::app::DummyConnector;
#[cfg(feature = "v2")] #[cfg(feature = "v2")]
@ -86,7 +88,7 @@ pub use self::app::Recon;
#[cfg(feature = "v2")] #[cfg(feature = "v2")]
pub use self::app::Tokenization; pub use self::app::Tokenization;
pub use self::app::{ pub use self::app::{
ApiKeys, AppState, ApplePayCertificatesMigration, Authentication, Cache, Cards, Configs, ApiKeys, AppState, ApplePayCertificatesMigration, Authentication, Cache, Cards, Chat, Configs,
ConnectorOnboarding, Customers, Disputes, EphemeralKey, FeatureMatrix, Files, Forex, Gsm, ConnectorOnboarding, Customers, Disputes, EphemeralKey, FeatureMatrix, Files, Forex, Gsm,
Health, Hypersense, Mandates, MerchantAccount, MerchantConnectorAccount, PaymentLink, Health, Hypersense, Mandates, MerchantAccount, MerchantConnectorAccount, PaymentLink,
PaymentMethods, Payments, Poll, ProcessTracker, Profile, ProfileAcquirer, ProfileNew, Refunds, PaymentMethods, Payments, Poll, ProcessTracker, Profile, ProfileAcquirer, ProfileNew, Refunds,

View File

@ -59,8 +59,8 @@ use super::verification::{apple_pay_merchant_registration, retrieve_apple_pay_ve
#[cfg(feature = "oltp")] #[cfg(feature = "oltp")]
use super::webhooks::*; use super::webhooks::*;
use super::{ use super::{
admin, api_keys, cache::*, connector_onboarding, disputes, files, gsm, health::*, profiles, admin, api_keys, cache::*, chat, connector_onboarding, disputes, files, gsm, health::*,
relay, user, user_role, profiles, relay, user, user_role,
}; };
#[cfg(feature = "v1")] #[cfg(feature = "v1")]
use super::{apple_pay_certificates_migration, blocklist, payment_link, webhook_events}; use super::{apple_pay_certificates_migration, blocklist, payment_link, webhook_events};
@ -2215,6 +2215,23 @@ impl Gsm {
} }
} }
pub struct Chat;
#[cfg(feature = "olap")]
impl Chat {
pub fn server(state: AppState) -> Scope {
let mut route = web::scope("/chat").app_data(web::Data::new(state.clone()));
if state.conf.chat.enabled {
route = route.service(
web::scope("/ai").service(
web::resource("/data")
.route(web::post().to(chat::get_data_from_hyperswitch_ai_workflow)),
),
);
}
route
}
}
pub struct ThreeDsDecisionRule; pub struct ThreeDsDecisionRule;
#[cfg(feature = "oltp")] #[cfg(feature = "oltp")]

View File

@ -0,0 +1,38 @@
use actix_web::{web, HttpRequest, HttpResponse};
#[cfg(feature = "olap")]
use api_models::chat as chat_api;
use router_env::{instrument, tracing, Flow};
use super::AppState;
use crate::{
core::{api_locking, chat as chat_core},
services::{
api,
authentication::{self as auth},
authorization::permissions::Permission,
},
};
#[instrument(skip_all)]
pub async fn get_data_from_hyperswitch_ai_workflow(
state: web::Data<AppState>,
http_req: HttpRequest,
payload: web::Json<chat_api::ChatRequest>,
) -> HttpResponse {
let flow = Flow::GetDataFromHyperswitchAiFlow;
Box::pin(api::server_wrap(
flow.clone(),
state,
&http_req,
payload.into_inner(),
|state, user: auth::UserFromToken, payload, _| {
chat_core::get_data_from_hyperswitch_ai_workflow(state, user, payload)
},
// At present, the AI service retrieves data scoped to the merchant level
&auth::JWTAuth {
permission: Permission::MerchantPaymentRead,
},
api_locking::LockAction::NotApplicable,
))
.await
}

View File

@ -35,6 +35,7 @@ pub enum ApiIdentifier {
UserRole, UserRole,
ConnectorOnboarding, ConnectorOnboarding,
Recon, Recon,
AiWorkflow,
Poll, Poll,
ApplePayCertificatesMigration, ApplePayCertificatesMigration,
Relay, Relay,
@ -300,6 +301,8 @@ impl From<Flow> for ApiIdentifier {
| Flow::DeleteTheme | Flow::DeleteTheme
| Flow::CloneConnector => Self::User, | Flow::CloneConnector => Self::User,
Flow::GetDataFromHyperswitchAiFlow => Self::AiWorkflow,
Flow::ListRolesV2 Flow::ListRolesV2
| Flow::ListInvitableRolesAtEntityLevel | Flow::ListInvitableRolesAtEntityLevel
| Flow::ListUpdatableRolesAtEntityLevel | Flow::ListUpdatableRolesAtEntityLevel

View File

@ -349,6 +349,8 @@ pub enum Flow {
ApplePayCertificatesMigration, ApplePayCertificatesMigration,
/// Gsm Rule Delete flow /// Gsm Rule Delete flow
GsmRuleDelete, GsmRuleDelete,
/// Get data from embedded flow
GetDataFromHyperswitchAiFlow,
/// User Sign Up /// User Sign Up
UserSignUp, UserSignUp,
/// User Sign Up /// User Sign Up

View File

@ -724,3 +724,7 @@ redsys = { payment_method = "card" }
billing_connectors_which_require_payment_sync = "stripebilling, recurly" billing_connectors_which_require_payment_sync = "stripebilling, recurly"
[billing_connectors_invoice_sync] [billing_connectors_invoice_sync]
billing_connectors_which_requires_invoice_sync_call = "recurly" billing_connectors_which_requires_invoice_sync_call = "recurly"
[chat]
enabled = false
hyperswitch_ai_host = "http://0.0.0.0:8000"