Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
699e64f
feat: add external api for chat
Riddhiagrawal001 Jun 24, 2025
86b3590
Merge branch 'main' into feat/add-external-chat-api
apoorvdixit88 Jul 8, 2025
e36239e
feat(ai): add endpoints to chat with ai service
apoorvdixit88 Jul 9, 2025
d1e93db
chore: run formatter
hyperswitch-bot[bot] Jul 9, 2025
cbcbec2
fix: correct feature flag error
apoorvdixit88 Jul 9, 2025
d51606e
chore: run formatter
hyperswitch-bot[bot] Jul 9, 2025
73abca3
fix: correct v2 error
apoorvdixit88 Jul 9, 2025
f06d12f
fix: correct settings for chat
apoorvdixit88 Jul 9, 2025
dee0cf2
fix: correct request response type of ai services
apoorvdixit88 Jul 10, 2025
a86dd3e
fix: change request type for embedded
apoorvdixit88 Jul 10, 2025
1903e14
fix: take parameters of request from token
apoorvdixit88 Jul 10, 2025
99ab29e
Merge branch 'main' into feat/add-external-chat-api
apoorvdixit88 Jul 10, 2025
2b2a407
fix: remove context of automation ai
apoorvdixit88 Jul 11, 2025
26fbaba
Merge branch 'main' into feat/add-external-chat-api
apoorvdixit88 Jul 11, 2025
46aca04
fix: change function names
apoorvdixit88 Jul 11, 2025
4841595
fix: rename functions
apoorvdixit88 Jul 11, 2025
09e9c12
fix: add logger and increase time out
apoorvdixit88 Jul 15, 2025
f92fe4d
Merge branch 'main' into feat/add-external-chat-api
apoorvdixit88 Jul 15, 2025
633acbb
chore: run formatter
hyperswitch-bot[bot] Jul 15, 2025
2b97571
fix: resolve review comments
apoorvdixit88 Jul 16, 2025
eaad65a
fix: skip serializing query_executed
apoorvdixit88 Jul 16, 2025
cbcd834
fix: remove row_count from response
apoorvdixit88 Jul 16, 2025
97e9d60
fix: update comment for env config
apoorvdixit88 Jul 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1132,4 +1132,8 @@ version = "HOSTNAME" # value of HOSTNAME from deployment which tells its

[platform]
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
6 changes: 5 additions & 1 deletion config/deployments/env_specific.toml
Original file line number Diff line number Diff line change
Expand Up @@ -380,4 +380,8 @@ connector_names = "connector_names" # Comma-separated list of allowed connec
[grpc_client.unified_connector_service]
host = "localhost" # Unified Connector Service Client Host
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
4 changes: 4 additions & 0 deletions config/development.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1225,3 +1225,7 @@ connector_names = "stripe, adyen" # Comma-separated list of allowe
[infra_values]
cluster = "CLUSTER"
version = "HOSTNAME"

[chat]
enabled = false
hyperswitch_ai_host = "http://0.0.0.0:8000"
4 changes: 4 additions & 0 deletions config/docker_compose.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,10 @@ background_color = "#FFFFFF"
enabled = true
allow_connected_merchants = true

[chat]
enabled = false
hyperswitch_ai_host = "http://0.0.0.0:8000"

[authentication_providers]
click_to_pay = {connector_list = "adyen, cybersource"}

Expand Down
18 changes: 18 additions & 0 deletions crates/api_models/src/chat.rs
Original file line number Diff line number Diff line change
@@ -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>,
}
1 change: 1 addition & 0 deletions crates/api_models/src/events.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod apple_pay_certificates_migration;
pub mod chat;
pub mod connector_onboarding;
pub mod customer;
pub mod dispute;
Expand Down
5 changes: 5 additions & 0 deletions crates/api_models/src/events/chat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
use common_utils::events::{ApiEventMetric, ApiEventsType};

use crate::chat::{ChatRequest, ChatResponse};

common_utils::impl_api_event_type!(Chat, (ChatRequest, ChatResponse));
1 change: 1 addition & 0 deletions crates/api_models/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod apple_pay_certificates_migration;
pub mod authentication;
pub mod blocklist;
pub mod cards_info;
pub mod chat;
pub mod conditional_configs;
pub mod connector_enums;
pub mod connector_onboarding;
Expand Down
3 changes: 3 additions & 0 deletions crates/common_utils/src/consts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,6 @@ pub const METRICS_HOST_TAG_NAME: &str = "host";

/// API client request timeout (in seconds)
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;
1 change: 1 addition & 0 deletions crates/common_utils/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ pub enum ApiEventsType {
profile_acquirer_id: id_type::ProfileAcquirerId,
},
ThreeDsDecisionRule,
Chat,
}

impl ApiEventMetric for serde_json::Value {}
Expand Down
15 changes: 15 additions & 0 deletions crates/hyperswitch_domain_models/src/chat.rs
Original file line number Diff line number Diff line change
@@ -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,
Comment on lines +11 to +14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would there be a situation where tenant ID might come into picture when calling the AI service?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now we are only considering merchant_id and giving result for merchant level data, if tenant id comes into picture we may have to make changes in the ai service as well to talk to the corresponding schema. Not sure, will depend on the tenant, how they want this service. This all can be as per the use case.

}
1 change: 1 addition & 0 deletions crates/hyperswitch_domain_models/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pub mod business_profile;
pub mod callback_mapper;
pub mod card_testing_guard_data;
pub mod cards_info;
pub mod chat;
pub mod connector_endpoints;
pub mod consts;
pub mod customer;
Expand Down
1 change: 1 addition & 0 deletions crates/router/src/configs/secrets_transformers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ pub(crate) async fn fetch_raw_secrets(

Settings {
server: conf.server,
chat: conf.chat,
master_database,
redis: conf.redis,
log: conf.log,
Expand Down
9 changes: 9 additions & 0 deletions crates/router/src/configs/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ pub struct Settings<S: SecretState> {
pub server: Server,
pub proxy: Proxy,
pub env: Env,
pub chat: ChatSettings,
pub master_database: SecretStateContainer<Database, S>,
#[cfg(feature = "olap")]
pub replica_database: SecretStateContainer<Database, S>,
Expand Down Expand Up @@ -195,6 +196,13 @@ pub struct Platform {
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)]
pub struct Multitenancy {
pub tenants: TenantConfig,
Expand Down Expand Up @@ -1016,6 +1024,7 @@ impl Settings<SecuredSecret> {
self.secrets.get_inner().validate()?;
self.locker.validate()?;
self.connectors.validate("connectors")?;
self.chat.validate()?;

self.cors.validate()?;

Expand Down
12 changes: 12 additions & 0 deletions crates/router/src/configs/validations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
))
})
}
}
1 change: 1 addition & 0 deletions crates/router/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,5 @@ pub mod relay;
#[cfg(feature = "v2")]
pub mod revenue_recovery;

pub mod chat;
pub mod tokenization;
57 changes: 57 additions & 0 deletions crates/router/src/core/chat.rs
Original file line number Diff line number Diff line change
@@ -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))
}
1 change: 1 addition & 0 deletions crates/router/src/core/errors.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod chat;
pub mod customers_error_response;
pub mod error_handlers;
pub mod transformers;
Expand Down
37 changes: 37 additions & 0 deletions crates/router/src/core/errors/chat.rs
Original file line number Diff line number Diff line change
@@ -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(),
}
}
}
3 changes: 2 additions & 1 deletion crates/router/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,8 @@ pub fn mk_app(
.service(routes::MerchantAccount::server(state.clone()))
.service(routes::User::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")]
{
Expand Down
4 changes: 3 additions & 1 deletion crates/router/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ pub mod process_tracker;
#[cfg(feature = "v2")]
pub mod proxy;

pub mod chat;

#[cfg(feature = "dummy_connector")]
pub use self::app::DummyConnector;
#[cfg(feature = "v2")]
Expand All @@ -86,7 +88,7 @@ pub use self::app::Recon;
#[cfg(feature = "v2")]
pub use self::app::Tokenization;
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,
Health, Hypersense, Mandates, MerchantAccount, MerchantConnectorAccount, PaymentLink,
PaymentMethods, Payments, Poll, ProcessTracker, Profile, ProfileAcquirer, ProfileNew, Refunds,
Expand Down
21 changes: 19 additions & 2 deletions crates/router/src/routes/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ use super::verification::{apple_pay_merchant_registration, retrieve_apple_pay_ve
#[cfg(feature = "oltp")]
use super::webhooks::*;
use super::{
admin, api_keys, cache::*, connector_onboarding, disputes, files, gsm, health::*, profiles,
relay, user, user_role,
admin, api_keys, cache::*, chat, connector_onboarding, disputes, files, gsm, health::*,
profiles, relay, user, user_role,
};
#[cfg(feature = "v1")]
use super::{apple_pay_certificates_migration, blocklist, payment_link, webhook_events};
Expand Down Expand Up @@ -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;

#[cfg(feature = "oltp")]
Expand Down
38 changes: 38 additions & 0 deletions crates/router/src/routes/chat.rs
Original file line number Diff line number Diff line change
@@ -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(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Should we consider having #[instrument] here as well?

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,
},
Comment on lines +32 to +34
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any specific reason for choosing this permission? If yes, should we consider explaining it in code comments?

api_locking::LockAction::NotApplicable,
))
.await
}
Loading
Loading