Files
foxhunt/services/api/src/grpc/ml_proxy.rs
jgrusewski e50ea55064 feat: create services/api/ — unified gRPC gateway with tonic-web
Copied from api_gateway, removed REST handlers (port 8080),
added tonic-web + CORS for grpc-web browser access.
Binary renamed: api-gateway → api

Changes:
- Package name: api-gateway → api
- Deleted src/handlers/ (REST ML endpoints on port 8080)
- Added tonic-web 0.13 + tower-http CORS layer
- Server::builder().accept_http1(true) for grpc-web
- CORS_ORIGINS env var (default http://localhost:5173)
- Metrics server on port 9091 (axum) preserved
- All 95 lib tests pass, 0 clippy warnings
- Added services/api to workspace members

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:32:46 +01:00

231 lines
7.5 KiB
Rust

//! ML Inference Service Proxy - Zero-copy gRPC forwarding for ml.MLService
//!
//! Forwards to trading-service backend (ML inference lives in same process).
use futures::Stream;
use std::pin::Pin;
use std::time::Duration;
use tonic::{Request, Response, Status};
use tracing::{error, instrument};
use crate::ml_inference::ml_service_client::MlServiceClient;
use crate::ml_inference::ml_service_server::MlService;
use crate::ml_inference::{
GetAvailableModelsRequest, GetAvailableModelsResponse, GetEnsembleVoteRequest,
GetEnsembleVoteResponse, GetFeatureImportanceRequest, GetFeatureImportanceResponse,
GetModelPerformanceRequest, GetModelPerformanceResponse, GetModelStatusRequest,
GetModelStatusResponse, GetPredictionRequest, GetPredictionResponse, ModelMetricsEvent,
PredictionEvent, RetrainModelRequest, RetrainModelResponse, SignalStrengthEvent,
StreamModelMetricsRequest, StreamModelStatusRequest, StreamPredictionsRequest,
StreamSignalStrengthRequest,
};
#[derive(Debug, Clone)]
pub struct MlServiceProxy {
client: MlServiceClient<tonic::transport::Channel>,
}
impl MlServiceProxy {
pub fn new(client: MlServiceClient<tonic::transport::Channel>) -> Self {
Self { client }
}
}
#[tonic::async_trait]
impl MlService for MlServiceProxy {
type StreamPredictionsStream =
Pin<Box<dyn Stream<Item = Result<PredictionEvent, Status>> + Send>>;
type StreamModelMetricsStream =
Pin<Box<dyn Stream<Item = Result<ModelMetricsEvent, Status>> + Send>>;
type StreamSignalStrengthStream =
Pin<Box<dyn Stream<Item = Result<SignalStrengthEvent, Status>> + Send>>;
type StreamModelStatusStream =
Pin<Box<dyn Stream<Item = Result<GetModelStatusResponse, Status>> + Send>>;
#[instrument(skip(self, request), err)]
async fn get_prediction(
&self,
request: Request<GetPredictionRequest>,
) -> Result<Response<GetPredictionResponse>, Status> {
self.client.clone().get_prediction(request).await.map_err(|e| {
error!("Backend GetPrediction failed: {}", e);
e
})
}
#[instrument(skip(self, request), err)]
async fn stream_predictions(
&self,
request: Request<StreamPredictionsRequest>,
) -> Result<Response<Self::StreamPredictionsStream>, Status> {
let stream = self
.client
.clone()
.stream_predictions(request)
.await
.map_err(|e| {
error!("Backend StreamPredictions failed: {}", e);
e
})?;
Ok(Response::new(Box::pin(stream.into_inner())))
}
#[instrument(skip(self, request), err)]
async fn get_ensemble_vote(
&self,
request: Request<GetEnsembleVoteRequest>,
) -> Result<Response<GetEnsembleVoteResponse>, Status> {
self.client
.clone()
.get_ensemble_vote(request)
.await
.map_err(|e| {
error!("Backend GetEnsembleVote failed: {}", e);
e
})
}
#[instrument(skip(self, request), err)]
async fn get_model_status(
&self,
request: Request<GetModelStatusRequest>,
) -> Result<Response<GetModelStatusResponse>, Status> {
self.client
.clone()
.get_model_status(request)
.await
.map_err(|e| {
error!("Backend GetModelStatus failed: {}", e);
e
})
}
#[instrument(skip(self, request), err)]
async fn get_available_models(
&self,
request: Request<GetAvailableModelsRequest>,
) -> Result<Response<GetAvailableModelsResponse>, Status> {
self.client
.clone()
.get_available_models(request)
.await
.map_err(|e| {
error!("Backend GetAvailableModels failed: {}", e);
e
})
}
#[instrument(skip(self, request), err)]
async fn retrain_model(
&self,
request: Request<RetrainModelRequest>,
) -> Result<Response<RetrainModelResponse>, Status> {
self.client
.clone()
.retrain_model(request)
.await
.map_err(|e| {
error!("Backend RetrainModel failed: {}", e);
e
})
}
#[instrument(skip(self, request), err)]
async fn get_model_performance(
&self,
request: Request<GetModelPerformanceRequest>,
) -> Result<Response<GetModelPerformanceResponse>, Status> {
self.client
.clone()
.get_model_performance(request)
.await
.map_err(|e| {
error!("Backend GetModelPerformance failed: {}", e);
e
})
}
#[instrument(skip(self, request), err)]
async fn stream_model_metrics(
&self,
request: Request<StreamModelMetricsRequest>,
) -> Result<Response<Self::StreamModelMetricsStream>, Status> {
let stream = self
.client
.clone()
.stream_model_metrics(request)
.await
.map_err(|e| {
error!("Backend StreamModelMetrics failed: {}", e);
e
})?;
Ok(Response::new(Box::pin(stream.into_inner())))
}
#[instrument(skip(self, request), err)]
async fn get_feature_importance(
&self,
request: Request<GetFeatureImportanceRequest>,
) -> Result<Response<GetFeatureImportanceResponse>, Status> {
self.client
.clone()
.get_feature_importance(request)
.await
.map_err(|e| {
error!("Backend GetFeatureImportance failed: {}", e);
e
})
}
#[instrument(skip(self, request), err)]
async fn stream_signal_strength(
&self,
request: Request<StreamSignalStrengthRequest>,
) -> Result<Response<Self::StreamSignalStrengthStream>, Status> {
let stream = self
.client
.clone()
.stream_signal_strength(request)
.await
.map_err(|e| {
error!("Backend StreamSignalStrength failed: {}", e);
e
})?;
Ok(Response::new(Box::pin(stream.into_inner())))
}
#[instrument(skip(self, request), err)]
async fn stream_model_status(
&self,
request: Request<StreamModelStatusRequest>,
) -> Result<Response<Self::StreamModelStatusStream>, Status> {
let req = request.into_inner();
let interval_secs = if req.interval_seconds == 0 {
5
} else {
req.interval_seconds.clamp(1, 60)
};
let model_name = req.model_name;
let mut client = self.client.clone();
let stream = async_stream::stream! {
let mut interval = tokio::time::interval(Duration::from_secs(u64::from(interval_secs)));
loop {
interval.tick().await;
match client.get_model_status(Request::new(GetModelStatusRequest {
model_name: if model_name.is_empty() { None } else { Some(model_name.clone()) },
})).await {
Ok(resp) => yield Ok(resp.into_inner()),
Err(e) => {
error!("Backend GetModelStatus failed: {}", e);
yield Err(e);
break;
}
}
}
};
Ok(Response::new(Box::pin(stream)))
}
}