@@ -11,26 +11,31 @@ use axum::body::Body;
1111use axum:: extract:: ws:: { CloseFrame , WebSocket } ;
1212use axum:: extract:: { self , ConnectInfo , Query , State , WebSocketUpgrade } ;
1313use axum:: http:: header:: { CONTENT_DISPOSITION , CONTENT_TYPE , HeaderValue } ;
14- use axum:: response:: Response ;
15- use axum:: routing:: { delete, get} ;
14+ use axum:: response:: { IntoResponse as _ , Response } ;
15+ use axum:: routing:: { delete, get, post } ;
1616use axum:: { Json , Router } ;
1717use bytes:: Bytes ;
1818use cadeau:: xmf;
1919use camino:: { Utf8Path , Utf8PathBuf } ;
2020use devolutions_gateway_task:: ShutdownSignal ;
2121use futures:: stream;
2222use hyper:: StatusCode ;
23+ use secrecy:: SecretString ;
2324use tokio:: io:: AsyncReadExt as _;
2425use tracing:: Instrument as _;
26+ use url:: Url ;
2527use uuid:: Uuid ;
2628use zip:: CompressionMethod ;
2729use zip:: write:: SimpleFileOptions ;
2830
2931use crate :: DgwState ;
3032use crate :: api:: heartbeat:: recording_storage_health;
33+ use crate :: api:: tasks:: TaskInfo ;
3134use crate :: artifacts:: JrecArtifacts ;
32- use crate :: extract:: { JrecToken , RecordingDeleteScope , RecordingsReadScope } ;
35+ use crate :: extract:: { JrecToken , RecordingAnalyzeScope , RecordingDeleteScope , RecordingsReadScope } ;
3336use crate :: http:: { HttpError , HttpErrorBuilder } ;
37+ use crate :: provisioner_tasks:: ai:: { AiProvider , AiSettings } ;
38+ use crate :: provisioner_tasks:: recording_ai_analysis:: { self , StartError , StartOutcome , StartRequest } ;
3439use crate :: recording:: { PushOutcome , RecordingMessageSender } ;
3540use crate :: token:: { JrecTokenClaims , RecordingFileType , RecordingOperation } ;
3641
@@ -59,6 +64,7 @@ pub fn make_router<S>(state: DgwState) -> Router<S> {
5964 . route ( "/play" , get ( get_player) )
6065 . route ( "/play/{*path}" , get ( get_player) )
6166 . route ( "/shadow/{id}" , get ( shadow_recording) )
67+ . route ( "/{session_id}/ai-analysis" , post ( start_ai_analysis) )
6268 . with_state ( state)
6369}
6470
@@ -426,6 +432,147 @@ async fn delete_recording(recording_path: &Utf8Path) -> anyhow::Result<()> {
426432 Ok ( ( ) )
427433}
428434
435+ /// Settings of an AI analysis
436+ #[ cfg_attr( feature = "openapi" , derive( utoipa:: ToSchema ) ) ]
437+ #[ derive( Deserialize ) ]
438+ #[ serde( rename_all = "camelCase" , deny_unknown_fields) ]
439+ pub ( crate ) struct AiAnalysisRequest {
440+ /// Task ID, chosen by the caller so it can send the same request again safely.
441+ task_id : Uuid ,
442+ provider : AiProvider ,
443+ /// Model identifier, passed to the provider as is.
444+ model : String ,
445+ /// Endpoint of the provider API, kept in memory only; required for `openai-compatible`.
446+ #[ cfg_attr( feature = "openapi" , schema( value_type = Option <String >) ) ]
447+ #[ serde( default ) ]
448+ base_url : Option < Url > ,
449+ /// Upper bound of tokens in each AI answer.
450+ #[ serde( default ) ]
451+ max_output_tokens : Option < u32 > ,
452+ /// API key of the provider, kept in memory only until the Task ends.
453+ #[ cfg_attr( feature = "openapi" , schema( value_type = String , format = Password ) ) ]
454+ api_key : SecretString ,
455+ }
456+
457+ /// Why an AI analysis cannot start now
458+ #[ cfg_attr( feature = "openapi" , derive( utoipa:: ToSchema ) ) ]
459+ #[ derive( Debug , Serialize ) ]
460+ #[ serde( rename_all = "camelCase" ) ]
461+ pub ( crate ) struct AiAnalysisConflict {
462+ code : AiAnalysisConflictCode ,
463+ /// The AI analysis of this session that is not finished yet; set for `analysis_in_progress`.
464+ #[ serde( skip_serializing_if = "Option::is_none" ) ]
465+ task_id : Option < Uuid > ,
466+ }
467+
468+ #[ cfg_attr( feature = "openapi" , derive( utoipa:: ToSchema ) ) ]
469+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Serialize ) ]
470+ #[ serde( rename_all = "snake_case" ) ]
471+ pub ( crate ) enum AiAnalysisConflictCode {
472+ /// The Task ID is already used by a Task with other parameters.
473+ TaskIdConflict ,
474+ /// The session is still recording.
475+ RecordingActive ,
476+ /// Another AI analysis of this session is not finished yet.
477+ AnalysisInProgress ,
478+ }
479+
480+ fn ai_analysis_conflict ( code : AiAnalysisConflictCode , task_id : Option < Uuid > ) -> Response {
481+ debug ! ( ?code, task. id = ?task_id, "AI analysis refused" ) ;
482+ ( StatusCode :: CONFLICT , Json ( AiAnalysisConflict { code, task_id } ) ) . into_response ( )
483+ }
484+
485+ /// Starts an AI analysis of a recorded session
486+ ///
487+ /// Gateway asks the AI provider what the user did in the session recording, and adds the answer to the session as a
488+ /// new log, listed under `artifacts.ai-analysis` in its manifest.
489+ /// The work runs in the background as a Task; follow it with `GET /jet/tasks/{id}`.
490+ ///
491+ /// Sending the same request again returns the same Task and starts nothing new.
492+ /// The API key and the base URL are kept in memory only, until the Task ends; a Gateway restart fails the unfinished
493+ /// Tasks.
494+ #[ cfg_attr( feature = "openapi" , utoipa:: path(
495+ post,
496+ operation_id = "StartAiAnalysis" ,
497+ tag = "Jrec" ,
498+ path = "/jet/jrec/{session_id}/ai-analysis" ,
499+ params(
500+ ( "session_id" = Uuid , Path , description = "Recorded session ID" ) ,
501+ ) ,
502+ request_body( content = AiAnalysisRequest , description = "AI analysis settings" , content_type = "application/json" ) ,
503+ responses(
504+ ( status = 200 , description = "The same request came in before; nothing new starts" , body = TaskInfo ) ,
505+ ( status = 202 , description = "The AI analysis Task is created and runs in the background" , body = TaskInfo ) ,
506+ ( status = 400 , description = "Invalid request body or AI settings" ) ,
507+ ( status = 401 , description = "Invalid or missing authorization token" ) ,
508+ ( status = 403 , description = "Insufficient permissions" ) ,
509+ ( status = 404 , description = "The session has no recording" ) ,
510+ ( status = 409 , description = "The AI analysis cannot start now" , body = AiAnalysisConflict ) ,
511+ ( status = 500 , description = "Unexpected server error" ) ,
512+ ) ,
513+ security( ( "scope_token" = [ "gateway.recording.analyze" ] ) ) ,
514+ ) ) ]
515+ pub ( crate ) async fn start_ai_analysis (
516+ State ( state) : State < DgwState > ,
517+ _scope : RecordingAnalyzeScope ,
518+ extract:: Path ( session_id) : extract:: Path < Uuid > ,
519+ body : Bytes ,
520+ ) -> Result < Response , HttpError > {
521+ let request: AiAnalysisRequest = serde_json:: from_slice ( & body) . map_err ( |error| {
522+ // The serde message may quote a value of the body, such as a misplaced API key, so only its place is logged.
523+ debug ! (
524+ category = ?error. classify( ) ,
525+ line = error. line( ) ,
526+ column = error. column( ) ,
527+ "Invalid AI analysis request body"
528+ ) ;
529+ HttpError :: bad_request ( ) . msg ( "invalid AI analysis request body" )
530+ } ) ?;
531+
532+ let AiAnalysisRequest {
533+ task_id,
534+ provider,
535+ model,
536+ base_url,
537+ max_output_tokens,
538+ api_key,
539+ } = request;
540+
541+ let outcome = recording_ai_analysis:: start (
542+ & state,
543+ StartRequest {
544+ task_id,
545+ session_id,
546+ settings : AiSettings {
547+ provider,
548+ model,
549+ base_url,
550+ max_output_tokens,
551+ } ,
552+ api_key,
553+ } ,
554+ )
555+ . await ;
556+
557+ match outcome {
558+ Ok ( StartOutcome :: Created ( task) ) => Ok ( ( StatusCode :: ACCEPTED , Json ( TaskInfo :: from ( task) ) ) . into_response ( ) ) ,
559+ Ok ( StartOutcome :: Existing ( task) ) => Ok ( ( StatusCode :: OK , Json ( TaskInfo :: from ( task) ) ) . into_response ( ) ) ,
560+ Ok ( StartOutcome :: IdConflict ( _) ) => Ok ( ai_analysis_conflict ( AiAnalysisConflictCode :: TaskIdConflict , None ) ) ,
561+ Ok ( StartOutcome :: TargetBusy { active_task_id } ) => Ok ( ai_analysis_conflict (
562+ AiAnalysisConflictCode :: AnalysisInProgress ,
563+ Some ( active_task_id) ,
564+ ) ) ,
565+ Err ( StartError :: RecordingActive ) => Ok ( ai_analysis_conflict ( AiAnalysisConflictCode :: RecordingActive , None ) ) ,
566+ Err ( StartError :: InvalidSettings ( error) ) => {
567+ Err ( HttpError :: bad_request ( ) . with_msg ( "invalid AI settings" ) . build ( error) )
568+ }
569+ Err ( StartError :: RecordingNotFound ) => Err ( HttpError :: not_found ( ) . msg ( "session has no recording" ) ) ,
570+ Err ( StartError :: Internal ( error) ) => Err ( HttpError :: internal ( )
571+ . with_msg ( "failed to start the AI analysis" )
572+ . build ( error) ) ,
573+ }
574+ }
575+
429576/// Lists all recordings stored on this instance
430577#[ cfg_attr( feature = "openapi" , utoipa:: path(
431578 get,
@@ -1030,6 +1177,52 @@ mod tests {
10301177
10311178 use super :: * ;
10321179
1180+ #[ tokio:: test]
1181+ async fn ai_analysis_of_a_session_still_recording_is_a_conflict ( ) {
1182+ use axum:: extract:: connect_info:: MockConnectInfo ;
1183+ use base64:: Engine as _;
1184+ use tower:: ServiceExt as _;
1185+
1186+ let dir = tempfile:: tempdir ( ) . expect ( "temp dir" ) ;
1187+ let config = serde_json:: json!( {
1188+ "ProvisionerPublicKeyData" : {
1189+ "Value" : "mMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4vuqLOkl1pWobt6su1XO9VskgCAwevEGs6kkNjJQBwkGnPKYLmNF1E/af1yCocfVn/OnPf9e4x+lXVyZ6LMDJxFxu+axdgOq3Ld392J1iAEbfvwlyRFnEXFOJNyylqg3bY6LvnWHL/XZczVdMD9xYfq2sO9bg3xjRW4s7r9EEYOFjqVT3VFznH9iWJVtcSEKukmS/3uKoO6lGhacvu0HhjXXdgq0R8zvR4XRJ9Fcnf0f9Ypoc+i6L80NVjrRCeVOH+Ld/2fA9bocpfLarcVqG3RjS+qgOtpyCc0jWVFF4zaGQ7LUDFkEIYILkICeMMn2ll29hmZNzsJzZJ9s6NocgQIDAQAB"
1190+ } ,
1191+ "Listeners" : [ { "InternalUrl" : "http://*:7171" , "ExternalUrl" : "https://*:7171" } ] ,
1192+ "RecordingPath" : dir. path( ) . to_str( ) . expect( "UTF-8" ) ,
1193+ "__debug__" : { "disable_token_validation" : true } ,
1194+ } ) ;
1195+ let ( state, _handles) = DgwState :: mock ( & config. to_string ( ) ) . await . expect ( "mock state" ) ;
1196+ let session_id = Uuid :: new_v4 ( ) ;
1197+ state. recordings . active_recordings . insert ( session_id) ;
1198+ let app = crate :: make_http_service ( state) . layer ( MockConnectInfo ( SocketAddr :: from ( ( [ 127 , 0 , 0 , 1 ] , 3000 ) ) ) ) ;
1199+
1200+ let engine = base64:: engine:: general_purpose:: URL_SAFE_NO_PAD ;
1201+ let claims = serde_json:: json!( { "jti" : Uuid :: new_v4( ) , "exp" : i64 :: from( u32 :: MAX ) , "scope" : "gateway.recording.analyze" } ) ;
1202+ let token = format ! (
1203+ "{}.{}.{}" ,
1204+ engine. encode( r#"{"alg":"RS256","cty":"SCOPE"}"# ) ,
1205+ engine. encode( claims. to_string( ) ) ,
1206+ engine. encode( b"signature" ) ,
1207+ ) ;
1208+ let body = serde_json:: json!( { "taskId" : Uuid :: new_v4( ) , "provider" : "openai" , "model" : "gpt-test" , "apiKey" : "sk-test" } ) ;
1209+ let request = axum:: http:: Request :: builder ( )
1210+ . method ( "POST" )
1211+ . uri ( format ! ( "/jet/jrec/{session_id}/ai-analysis" ) )
1212+ . header ( axum:: http:: header:: AUTHORIZATION , format ! ( "Bearer {token}" ) )
1213+ . body ( Body :: from ( body. to_string ( ) ) )
1214+ . expect ( "request" ) ;
1215+
1216+ let response = app. oneshot ( request) . await . expect ( "response" ) ;
1217+
1218+ assert_eq ! ( response. status( ) , StatusCode :: CONFLICT ) ;
1219+ let body = response. into_body ( ) . collect ( ) . await . expect ( "body" ) . to_bytes ( ) ;
1220+ assert_eq ! (
1221+ serde_json:: from_slice:: <serde_json:: Value >( & body) . expect( "JSON" ) ,
1222+ serde_json:: json!( { "code" : "recording_active" } )
1223+ ) ;
1224+ }
1225+
10331226 #[ test]
10341227 fn rejects_unsafe_recording_file_names ( ) {
10351228 assert ! ( is_safe_recording_file_name( "recording-0.webm" ) ) ;
0 commit comments